初步了解多线程概念

一个进程内如何做资源划分

思考地址空间和页表

![cpu 进程 虚拟内存 页表](C:\Users\Administrator\Desktop\cpu 进程 虚拟内存 页表.png)//先看如下代码
char* str = "hello world";
*str = 'H';
//一定会报错,段错误
//因为"hello world"会存放在字符串常量区(已初始化变量区),此处的变量只允许读,不允许写。当拿着地址空间的地址通过页表去访问这个str变量时,发现页表里的RWX权限是R,没有W,但是我们的赋值操作是W,所以此时MMU就抛出硬件异常,当前进程收到段错误信号,进程退出

在这里插入图片描述

如何看待地址空间和页表:

首先,地址空间是进程能看到的资源窗口,页表决定着进程真正拥有资源的情况(页表的映射关系),当合理地对地址空间和页表进行资源划分,我们可以对一个进程所有的资源进行分类(分成了内核区、命令行参数环境变量区、栈区、共享区、堆区、未初始化数据区、已初始化数据区、代码区)

细讲页表

页表也是一种数据结构,也要占用内存空间。

页表中的一行叫做一个条目。假设一个条目存一个地址4字节、是否命中1bit、RWX权限1bit、U/K权限1bit,再考虑内存对齐,先按一个条目6字节来算。以32位系统为例,地址空间有 2 32 2^{32} 232个地址,如果均是按照页表一一映射的话,那么页表就会有 2 32 2^{32} 232个条目,仅保存页表的情况下,就需要约24GB的空间,如果页表的1条目更大呢?岂不是装不下了。所以我们应该意识到,页表肯定不会和虚拟地址空间的地址一一对应的!

将计算机中的物理内存以page为单位划分成一个一个的小方块,称作页框,每个页框有一个编号叫做PFN;有了PFN,就能够计算出这个页框对应的物理地址,有了物理地址CPU就能够通过总线访问到对应的内存。为了把物理页框用软件的方式管理起来,Linux定义了struct Page{//内存的属性--4KB};这样一个数据结构,每一个struct Page{};数据结构就对应着一个实实在在的页框。操作系统就可以对物理内存做管理struct Page mem[];。软件的struct page和物理层面的PFN之间的关系转换式就是pfn_to_page()page_to_pfn()两个表达式:将PFN转换为struct Page{};指针,即struct Page{};的虚拟地址,下同)或者将struct Page{};转换为PFN。

磁盘也是按4KB来划分的,称为页帧。linux对于物理内存的管理算法称为伙伴系统,自行去了解。

理解虚拟内存与物理内存之间的转换

虚拟内存的地址0000 0000 0000 0000 0000 0000 0000 0000,在做内存映射的时候,按照10bit、10bit、12bit来取,用前10bit作为索引去找对应的页目录( 2 10 = 1024 b i t = 1 K B 2^{10}=1024bit=1KB 210=1024bit=1KB),以第2个10bit作为索引去找页表项(1KB),最后的12bit是页内偏移量( 2 12 = 4096 b i t = 4 K B 2^{12}=4096bit=4KB 212=4096bit=4KB)==>刚好是一个页框的大小,加上页表项中给的指定页框的起始物理地址就能找到对应的物理地址。

在这里插入图片描述

这种方式大大节约了内存空间!

在进行虚拟内存和物理内存的地址转换时,不仅要借助软件结构(页表),也要通过硬件逻辑(MMU),两者结合。

之前讲的进程=内核数据结构+进程对应的代码和数据,接下来我们要去理解线程

线程–Linux平台

对于windows,macos,linuxs等系统来说都通用的定义:进程内的一个执行流。我们要讲的是Linux系统的线程的具体实现过程。

线程的概念

虚拟内存决定了进程能够看到的“资源”,以前讲的fork创建子进程,会拷贝一份PCB、虚拟内存、页表,这让子进程和父进程看到同一份资源,这是让多个PCB指向拷贝多份的虚拟内存。那也有其他能看到同一份资源的方式,如创建多个PCB指向同一个虚拟内存,这就是线程,多个PCB共享同一块虚拟内存。

上面讲了可以通过虚拟内存和页表对进程进行资源划分,单个“进程”(有线程的进程)的执行粒度要更细。

同理,线程需要被OS先描述再管理!

windows用的是TCB thread control block,线程结构控制块。

一个线程要被执行或者被调度,就会涉及状态、id、优先级、上下文、栈等描述项,单纯从这个角度看,线程和进程有很多地方是重叠的。故linux在设计上并没有给线程专门设计数据结构,而是直接复用PCB的数据结构。给不同的线程划分虚拟内存中的一小块,又因为页表有很多的条目,所以可以给不同的线程划分不同的资源。

综上,线程在进程内部运行,即在地址空间内运行,拥有该进程的一部分资源!线程是一个进程内部的控制序列。一个进程必然包括了一堆的PCB、整个虚拟内存、整个页表、以及对应的物理内存,进程承担了分配资源的任务。一切进程至少包括一个执行线程。通过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

大白话讲就是进程去跟OS申请资源,它把申请的资源分发给线程。

1、线程是CPU调度的基本单位进程是承担分配系统资源的基本单位。之前讲的进程只不过是内部只有一个执行流而已,和现在理解的进程内部有多个执行流不冲突。

2、进程用来整体申请资源,线程去向进程要资源。

3、linux内核中有没有真正意义上的线程呢?严格来说是没有的,因为没有专门为线程创建数据结构,而是用进程PCB来模拟线程的,是一种完全属于自己的一套线程方案。

  • 好处是节可靠高效,维护成本大大降低。
  • 坏处:从平台和概念来说,OS和用户都只认线程,但是linux中没有线程数据结构,且linux无法直接提供创建线程的系统调用接口,而只能给我们提供轻量级进程的接口,解决方式:有专门的用户级线程库pthread==>libpthread-2.17.so

4、站在CPU的视角,每一个OCB都可以称之为轻量级进程。因为在所有平台来说(task_struct【线程/进程】<=进程)。

5、线程也有自己私有的资源,比如PCB属性上下文结构【能体现出线程动态运行的属性,时间片轮转】、栈结构【保存自己的栈帧、局部变量等】。

线程优点

  1. 创建一个新线程的代价要比创建一个新进程小得多。因为创建一个进程需要PCB、进程地址空间、页表,而创建一个线程只需要PCB。
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。首先,进程切换需要切换用户级页表、PCB、上下文、虚拟地址空间、cache数据,而线程切换需要切换PCB、上下文即可。cpu内还集成了cache(这个是硬件级缓存==>叫做高速缓存,还分成L1,L2等),故cpu<–cache<–内存,cache中会缓存许多热点数据(涉及命中率,热点数据需要时间跑出来的),所以线程切换不用过分更新cache数据,进程切换要全部更新cache。工作量减少主要体现在不用全部更新cache的热点数据
  3. 线程占用的资源要比进程少很多。
  4. 能充分利用多处理器的可并行数量。
  5. 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  6. 计算密集型应用(CPU资源),为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  7. I/O密集型应用(外设资源),为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

线程缺点

  1. 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  2. 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  3. 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  4. 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多。

线程异常

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

线程共享

线程一旦被创建,几乎该进程的所有资源都是被所有线程共享的(比如同一地址空间是共享的,因此Text Segment、Data Segment都是共享的,文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id等均为共享的)。当然线程也有自己私有的资源,比如PCB属性上下文结构【能体现出线程动态运行的属性,时间片轮转】、栈结构【保存自己的栈帧、局部变量等】。

创建线程

pthread_create函数

功能:为当前进程创建一个线程
#include <pthread.h>

原型
    int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数
    thread:类型pthread_t其实就是个无符号长整形
    attr:设为NULL
    start_routine:这个参数是函数指针,想让新线程去做的事情
	arg:(void *)函数指针的参数,用于给线程传参
返回值
    0表示创建成功;非0值表示失败,thread不会被设置值,不会设置对应的错误码

编译时使用原生线程库pthread

看一下这个用户级线程库

[yyq@VM-8-13-centos 2023_03_14_thread]$ ls /lib64/libpthread.* -al
-rw-r--r-- 1 root root 152194 May 19  2022 /lib64/libpthread.a
-rw-r--r-- 1 root root    222 May 18  2022 /lib64/libpthread.so
lrwxrwxrwx 1 root root     18 Jul 25  2022 /lib64/libpthread.so.0 -> libpthread-2.17.so //这个就是用户线程库,任何linux系统,都必须携带这个原生线程库

在编译的时候要引入这个pthread库。属于链接第三方库,必须要指明库的名称!以及指明库所在的路径!gcc编译指定路径 -I选项(头文件所在路径) -L选项(库文件所在路径) -l选项(库文件名称)gcc -o mythread mythread.c -l pthread 注意库名称是去掉lib和.a/.so!

//makefile文件
mythread:mythread.cc
	g++ -o $@ $^ -l pthread
.PHONY:clean
clean:
	rm mythread
-----------------------------------------
//mythread.cc文件
#include <iostream>
#include <assert.h>
#include <pthread.h>
#include <unistd.h>

using namespace std;

// 新线程
void *thread_routime(void* args)
{
    const char* name = (const char*) args;
    // 如果是一个执行流,那么不可能同时执行2个死循环
    while(1)
    {
        cout << args << ": 新线程" << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, NULL, thread_routime, (void*)"thread one");
    assert(n == 0);
    (void)n;

    //主线程
    while(true)
    {
        char tidbuffer[64];
        snprintf(tidbuffer, sizeof(tidbuffer), "0x%x", tid);//按16进制打印
        cout << "主线程正在运行! 新线程id:" << tidbuffer << endl;
        sleep(1);
    }
    return 0;
}

可以看到的现象是

[yyq@VM-8-13-centos 2023_03_14_thread]$ ./mythread 
thread one: 新线程
主线程正在运行! 新线程id:0x547ce700
thread one: 新线程
主线程正在运行! 新线程id:0x547ce700
^C//说明肯定是有2个执行流在跑,不然不可能有两个死循环

查看线程的命令行输入

查看进程的代码ps ajx | head -1 && ps ajx | grep mythread

查看线程的代码ps -aL

[yyq@VM-8-13-centos 2023_03_14_thread]$ ps ajx | head -1 && ps ajx | grep mythread
PPID   PID  PGID   SID TTY      TPGID STAT   UID    TIME COMMAND
31889 21610 21610 31834 pts/22   21610 Sl+    1003   0:00 grep --color=auto mythread
[yyq@VM-8-13-centos 2023_03_14_thread]$ ps -aL
  PID   LWP TTY          TIME CMD
16031 16031 pts/14   00:00:00 mythread
16031 16032 pts/14   00:00:00 mythread
26904 26904 pts/69   00:00:00 ps

LWP light weight process表示轻量级进程的id,16031是主线程,16032是新线程。所以CPU在调度的时候是以LWP为标识符表示特定的一个执行流当只有一个执行流的时候,PID和LWP是等价的。

thread参数的含义

thread本质是个无符号长整数,上述程序按16进制打印thread时,得到的是0x547ce700,应该是一个地址。

猜你喜欢

转载自blog.csdn.net/m0_61780496/article/details/129538336