• 【Linux】多线程


    一、线程

    1.线程概念

    什么是线程?
    1.在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
    2.一切进程至少都有一个执行线程。
    3.线程在进程内部运行,本质是在进程地址空间内运行。
    4.在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
    5.透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
    需要明确的是,一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的。
    在这里插入图片描述

    每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性。

    但如果我们在创建“进程”时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:

    在这里插入图片描述

    此时我们创建的实际上就是三个线程:
    1.其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的“线程是进程内部的一个执行分支”。
    2.同时我们也可以看出,线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

    理解什么线程,和我们之前认识的进程是什么关系
    在这里插入图片描述
    CPU看到的虽说还是task_struct,但已经比传统的进程要更轻量化了
    操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多,当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细

    如果一款操作系统要支持真的线程,那么就需要对这些线程进行管理。比如说创建线程、终止线程、调度线程、切换线程、给线程分配资源、释放资源以及回收资源等等,所有的这一套相比较进程都需要另起炉灶,搭建一套与进程平行的线程管理模块。

    因此,如果要支持真的线程一定会提高设计操作系统的复杂程度。在Linux看来,描述线程的控制块和描述进程的控制块是类似的,因此Linux并没有重新为线程设计数据结构,而是直接复用了进程控制块,所以我们说Linux中的所有执行流都叫做轻量级进程

    但也有支持真的线程的操作系统,比如Windows操作系统,因此Windows操作系统系统的实现逻辑一定比Linux操作系统的实现逻辑要复杂得多。

    2.二级页表

    linux是如何将进程地址空间中的逻辑地址转化为物理地址的呢?
    在这里插入图片描述

    以32位平台为例,其页表的映射过程如下:
    1.选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表。
    2.再选择虚拟地址的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址。
    3,最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据。

    物理内存实际是被划分成一个个4KB大小的页框的,而磁盘上的程序也是被划分成一个个4KB大小的页帧的,当内存和磁盘进行数据交换时也就是以4KB大小为单位进行加载和保存的。

    4KB实际上就是2的12次方个字节,也就是说一个页框中有2的12次方个字节,而访问内存的基本大小是1字节,因此一个页框中就有2的12次方个地址,于是我们就可以将剩下的12个比特位作为偏移量,从页框的起始地址处开始向后进行偏移,从而找到物理内存中某一个对应字节数据。

    使用页表定位任意一个内存字节位置:页框+页内偏移,也可以说是基地址+偏移量!

    这实际上就是我们所谓的二级页表,其中页目录项是一级页表,页表项是二级页表。

    每一个表项还是按10字节计算,页目录和页表的表项都是2的10次方个,因此一个表的大小就是2的10次方 * 10个字节,也就是10KB。而页目录有2的10次方个表项也就意味着页表有2的10次方个,也就是说一级页表有1张,二级页表有210张,总共算下来大概就是10MB,内存消耗并不高,因此Linux中实际就是这样映射的。

    上面所说的所有映射过程,都是由MMU(MemoryManagementUnit)这个硬件完成的,该硬件是集成在CPU内的。页表是一种软件映射,MMU是一种硬件映射,所以计算机进行虚拟地址到物理地址的转化采用的是软硬件结合的方式。

    思考:malloc如何申请内存?

    我们前面文章说到,malloc实际上申请空间只是先在虚拟内存上申请空间,如果在虚拟内存上申请成功,先不在物理内存申请空间以及构建对应的页表,当用户在真正使用申请的空间时,系统检测到没有申请对应的物理空间,此时,发生缺页中断,操作系统才会真正的在物理内存申请空间供用户使用。

    3.线程的优点

    • 创建一个新线程的代价要比创建一个新进程小得多
    • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
    • 线程占用的资源要比进程少很多
    • 能充分利用多处理器的可并行数量
    • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
    • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
    • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作

    释:
    计算密集型:主要消耗CPU资源,比如文件压缩和解压、加密或者解密过程等等与算法相关的操作
    IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。
    线程不是越多越好,一定要根据CPU的个数/核心数,选择合适的线程数。

    4.线程的缺点

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

    实例:

    #include 
    #include 
    #include 
    
    using namespace std;
    
    int g_val = 100;
    
    void *threadRun1(void *args) {
        while (true)
        {
            sleep(1);
            cout << "t1 thread..." << getpid() << "&g_val: " << &g_val << " ,g_val:" << g_val << endl;
        } }
    
    void *threadRun2(void *args) {
        char *p = "hello linux";int cnt=5;
        while (true)
        {
            sleep(1);
            cout << "t2 thread..." << getpid() << "&g_val: " << &g_val << " ,g_val:" << g_val << endl;
            cnt--;
            if(!cnt)*p = 'a';   //让这一个 线程崩溃 
            
        } }
    
    int main() {
        pthread_t t1, t2;
        pthread_create(&t1, nullptr, threadRun1, nullptr);
        pthread_create(&t1, nullptr, threadRun2, nullptr);
    
        while(true)
        {
            sleep(1);
            cout<<"main  thread..."<<getpid()<<endl;
        }
    
        return 0; }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    在这里插入图片描述
    可以看到t2线程因程序异常退出后,其他的线程也会退出,该线程发生段错误,OS会向该进程发送信号,处理信号时,整个进程会被退出。反映出代码的健壮性较低。单个线程可影响整个进程。

    实例:

    #include 
    #include 
    #include 
    
    using namespace std;
    
    int g_val = 100;
    
    void *threadRun1(void *args) {
        while (true)
        {
            sleep(1);
            cout << "t1 thread..." << getpid() << "&g_val: " << &g_val << " ,g_val:" << g_val << endl;
        } }
    
    void *threadRun2(void *args) {
        while (true)
        {
            sleep(1);
            cout << "t2 thread..." << getpid() << "&g_val: " << &g_val << " ,g_val:" << g_val++ << endl; //仅将该线程修改全局变量
        } }
    
    int main() {
        pthread_t t1, t2;
        pthread_create(&t1, nullptr, threadRun1, nullptr);
        pthread_create(&t1, nullptr, threadRun2, nullptr);
    
        while(true)
        {
            sleep(1);
            cout<<"main thread..."<<getpid()<<"&g_val: " << &g_val << " ,g_val:" << g_val <<endl;
        }
    
        return 0; }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    在这里插入图片描述
    进程中的单个执行流(线程)可修改全局变量。缺乏访问控制

    二、进程和线程

    线程共享进程数据,但也拥有自己的一部分数据:

    • 线程ID
    • 一组寄存器。(存储每个线程的上下文信息)
    • 。(每个线程都有临时的数据,需要压栈出栈,各线程间的栈不共享
    • errno。(C语言提供的全局变量,每个线程都有自己的)
    • 信号屏蔽字。
    • 调度优先级。

    进程的多个线程共享

    因为是在同一个地址空间,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:

    • 如果定义一个函数,在各线程中都可以调用
    • 如果定义一个全局变量,在各线程中都可以访问到

    除此之外,各线程还共享以下进程资源和环境:

    • 文件描述符表。(进程打开一个文件后,其他线程也能够看到)
    • 每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
    • 当前工作目录。(cwd)
    • 用户ID和组ID。

    三、线程控制

    1.POSIX线程库

    pthread线程库是应用层的原生线程库:

    • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
    • 要使用这些函数库,要通过引入头文
    • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

    错误检查:

    • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
    • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做。而是将错误代码通过返回值返回。
    • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小

    2.线程创建

    pthread_created: 创建线程

    #include 
     
        int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                           void *(*start_routine) (void *), void *arg);
     
        // Compile and link with -pthread. 使用需链接原生线程库 	  
        // pthread_t:实际为无符号整型,这里thread代表线程id 	  
        // attr:线程属性,一般不需要自己设置,默认为NULL   
    	// start_routine:函数指针,为该线程需执行的对应任务 	  
    	// arg:传入start_routine回调函数内的参数 	
    	// 创建成功返回0,失败返回-1,并对全局变量errno赋值,指示错误信息 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    pthread_self:获取自己的线程id

    #include    
    pthread_t pthread_self(void);   
    // Compile and link with -pthread. 
    
    • 1
    • 2
    • 3

    实例:

    #include
    #include
    #include
    
    using namespace std;
    
    
    void* thread_run(void *) {
        while(true)
        {
            cout<<"我是一个新线程,我的线程id是: "<<pthread_self()<<endl;
            sleep(1);
        } }
    
    int main() {
        pthread_t t1;
        pthread_create(&t1,nullptr,thread_run,nullptr);
    
        while(true)
        {
            cout<<"main thread..."<<endl;
            sleep(1);
        }
        return 0; } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    在这里插入图片描述

    线程查看命令 ps -aL
    pid和lwp相对的进程为主线程。lwp(light weight process)轻量级进程在这里插入图片描述

    3.线程等待

    一般而言,线程也是需要被等待的,如果不等待,可能会导致类似"僵尸进程"的问题。

    调用接口: pthread_join:等待线程

    #include    
    int pthread_join(pthread_t thread, void **retval);   
    // Compile and link with -pthread. // 等待成功返回0,失败返回错误码
     // thread:需要等待的线程
     //retval:输出型参数,获取线程函数的返回值(由于该函数的返回值是一级指针,所以必须要传二级指针才能带回) ```
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    实例:

    #include 
    #include 
    #include 
    
    using namespace std;
    
    void *thread_run(void *args) {
        char * name=(char*)args;
        while (true)
        {
            cout << "我是一个新线程,我的线程名是: "<<name<<" ,我的线程id是:" << pthread_self() << endl;
            sleep(2);
            break;  //两秒后线程跳出循环
        }
        delete name;
        return nullptr;  //线程正常退出 }
    
    int main() {
        pthread_t tids[10];
        for (int i = 0; i <10; i++) // 创建10个线程
        {
    
            char *tname = new char[64];
            snprintf(tname,64,"thread - %d",i+1);
            pthread_create(tids+i, nullptr, thread_run, tname);
        }
    
        for(int i=0;i<10;i++)
        {
            int n=pthread_join(tids[i],nullptr);
            if(n!=0)cerr<<"pthread_join error"<<endl;
        }
        cout<<"all thread qiut"<<endl;
        return 0; } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    在这里插入图片描述
    值得注意的是,如果线程异常退出,对错误的处理不是由线程完成,而是进程去完成的。线程中没有信号。
    由于这里是用指针接收,如果要返回的数据大于4个或8个字节,可以封装成结构体或者类返回

    4.线程终止

    线程终止有三种方法

    • 函数中return(main函数return表示主线程或者进程退出,其他线程函数return只代表当前线程退出)
    • 新线程通过调用pthread_exit终止(exit是终止进程,如果在线程中调用会终止整个进程)
    • 主线 程调用pthread_cancel,取消目标线程。

    接口:
    pthread_exit:终止当前线程

    #include    
    void pthread_exit(void *retval);   // Compile and link with -pthread. 
    // retval:返回值 
    
    • 1
    • 2
    • 3

    pthread_cancel:主线程取消其他线程

    #include    
    int pthread_cancel(pthread_t thread);   
    // Compile and link with -pthread. 
    // thread:目标线程 ```
    
    • 1
    • 2
    • 3
    • 4

    实例:

    #include 
    #include 
    #include 
    #include 
    
    using namespace std; void *thread_run1(void *arg) {
        printf("我是新线程[%s], 我的线程ID是:%lu\n", (const char *)arg, pthread_self());
        sleep(1);
        pthread_exit((void *)666); // 终止线程,返回值为666 }
    
    void *thread_run2(void *arg) {
        while (1)
        {
            printf("我是新线程[%s], 我的线程ID是:%lu\n", (const char *)arg, pthread_self());
            sleep(1);
        } }
    #define NUM 2
    
    int main() {
        pthread_t tid[NUM];
        pthread_create(tid + 0, NULL, thread_run1, (void *)"new thread");
        pthread_create(tid + 1, NULL, thread_run2, (void *)"new thread");
    
        pthread_cancel(tid[1]); // 取消第二个线程
        void *status = NULL;
        pthread_join(tid[0], &status);
        printf("ret: %d\n", (uint64_t)status);
    
        void *status1 = NULL;
        pthread_join(tid[1], &status1);
        printf("ret: %d\n", (uint64_t)status1);
    
        return 0; 
        } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    在这里插入图片描述

    5.线程分离

    1.默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏。
    2.但如果我们不关心线程的返回值,join也是一种负担,此时我们可以将该线程进行分离,后续当线程退出时就会自动释放线程资源。
    3.一个线程如果被分离了,这个线程依旧要使用该进程的资源,依旧在该进程内运行,甚至这个线程崩溃了一定会影响其他线程,只不过这个线程退出时不再需要主线程去join了,当这个线程退出时系统会自动回收该线程所对应的资源。
    4.可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
    5.joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

    分离之后的线程不需要被join,运行完毕之后会自动释放PCB,其作用类似与进程信号中的SIGCHLD。

    pthread_detach:分离线程

    #include    
    int pthread_detach(pthread_t thread);   
    // Compile and link with -pthread. 
    //thread:需分离的线程id,可以新线程分离自己,也可以主线程分离新线程 ```
    
    • 1
    • 2
    • 3
    • 4

    实例:

    #include    
    int pthread_detach(pthread_t thread);   
    // Compile and link with -pthread. 
    //thread:需分离的线程id,可以新线程分离自己,也可以主线程分离新线程 ```
    ```cpp
    #include 
    #include 
    #include 
    #include
    #include
    #include 
    
    
    using namespace std; void *thread_run1(void *arg) {
        int cnt = 5;
        while (cnt)
        {
            printf("我是新线程[%s], %d\n", (const char *)arg, cnt--);
            sleep(1);
        } }
    
    int main() {
        pthread_t td1;
        pthread_create(&td1, NULL, thread_run1, (void *)"new thread");
    
        pthread_detach(td1);
        int n=pthread_join(td1, nullptr);
        if(n!=0)
        {
            //等待失败
            cout<<"等待失败,错误码: "<< n <<" "<< errno<<": "<<strerror(n)<<endl;
        }
        sleep(5);
        return 0; } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    在这里插入图片描述

    线程自己分离
    在这里插入图片描述

    四、线程ID

    在这里插入图片描述

    回到上面的问题为什么ps -aL中查看到的线程id与程序运行中输出的id值不一样?

    首先LWP是操作系统内核中的,而右边的线程id是线程库中的.
    线程库跟我们以前学习的系统库其实是一个道理,由线程库封装系统调用接口,对这些线程进行管理,提供接口给用户,lwp是内核中的,而线程id其实是线程库中标识一个线程的依据。

    线程id是什么?
    其实线程id就是线程的虚拟地址在这里插入图片描述
    而操作系统内核中的LWP与线程id数量其实是1:1的关系,当新线程被创建,操作系统就会为它创建一个LWP。
    为了操作系统能够调度线程,LWP被保存在struct pthread结构体中。

    实例:

    #include 
    #include 
    #include
    #include 
    
    using namespace std;
    
    int g_val = 100;
    
    const string To(pthread_t num) {
        char buffer[64];
        snprintf(buffer,sizeof(buffer),"0x%x",num);
        return buffer; }
    
    void *threadRun1(void *args) {
        while (true)
        {
            sleep(1);
            cout << "t1 thread... "<<"我的线程id是: "<<pthread_self()<<" "<<To(pthread_self())<<endl;
        } }
    
    void *threadRun2(void *args) {
        while (true)
        {
            sleep(1);
            cout << "t2 thread... "<<"我的线程id是: "<<pthread_self()<<" "<<To(pthread_self())<<endl;
        } }
    
    int main() {
        pthread_t t1, t2;
        pthread_create(&t1, nullptr, threadRun1, (void*)"thread_1");
        pthread_create(&t2, nullptr, threadRun2, (void*)"thread_2");
    
        while(true)
        {
            sleep(1);
            cout<<"main thread..."<<"我的线程id是: "<<pthread_self()<<" "<<To(pthread_self())<<endl;
        }
        return 0; }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    在这里插入图片描述

    线程的独立栈
    在这里插入图片描述
    可以看到,在threadRun内部创建栈变量,每个线程的地址不同,因为每个线程都有自己独立的栈结构

    线程的局部存储
    在这里插入图片描述
    将一个全局变量添加__thread为线程的局部存储
    在这里插入图片描述

  • 相关阅读:
    【C++设计模式之组合模式:结构型】分析及示例
    「SpringCloud」10 Stream消息驱动
    Multisim14 逻辑分析仪的使用教程(打开&关闭+详细具体)
    Docker 容器的数据卷的使用
    Github已经54k个star的Docker,到底是什么?
    Java并发(十一)----线程五种状态与六种状态
    C++交换a和b的方法
    Springboot策略模式实现文件上传功能(Windows/Linux本地,oss,cos)
    Anaconda安装配置
    map原理理解
  • 原文地址:https://blog.csdn.net/Tianzhenchuan/article/details/133135542