0


Linux--进程多线程(上)

前言

    *精神内耗一方面可能是消极的,人好像一直在跟自己过不去,但其实它也是一种积极的情绪。精神内耗在某种程度上,是在寻找一种出口,寻找他自己人生的出口,寻找我今天的出口,或者寻找我一觉醒来明天的出口。我们从积极的角度谈论的话,精神内耗不是一个坏事。*
  •    ---余华*
    

正如余华老师说的,我们不要怕精神内耗,当内耗后找到出口后,那便是一个好事。无论如何都应脚踏实地走好每一步,今天这一步迈向多线程。由于线程内容比较多,我们分上下部分进行讲解。该章节主要是了解线程概念,理解线程与进程区别与联系,并且学会线程控制,线程创建,线程终止,线程等待。紧接着就是了解线程分离。

Linux线程概念

什么是线程

在理解线程之前,我们先理思考一个问题:如何理解下面代码报错?

char *str = "hello world";

*str = 'H';

在语言层面上,理解的是不能给常量赋值。在反汇编层面上,当前执行时“hello world”是被存放在寄存器上,寄存器上的内容是不能被修改的,所以不能被赋值。

一个是语言上的解释,一个是硬件上的解释。在操作系统中是如何解释的呢?我们先观察下面图中,虚拟地址通过页表到内存这一部分。

常量不能被赋值这是为什么?

    *其原因是,当对字符串常量解引用的时候,是需要找到该字符串的起始地址,然后对它做修改。这个字符串常量是在已初始化和代码区之间,当对字符串写的时候,是对str进行虚拟地址到物理地址的转化,在这个过程中经过页表,就会对它进行RWX权限查找,系统发现它只有R权限(这里可以结合上述反汇编理解,在执行时,str被存放在寄存器中,操作系统就在RWX给它只设置了R权限),地址转化单元MMU,直接就将这个行为终止,硬件直接保存。然后操作系统识别到硬件报错,把硬件报错转化成信号,系统默认终止处理。这个错误就是段错误(segmentation faul),在kill -l中就是信号11 SIGSEGV。*

理解线程之前,实则我们还是需要先把进程理解透彻,理解进程的时候我们也讲过上述整个框架图。在这个框架图,此时是关于页表理解。之前在信号阶段,讲过虚拟地址空间中有3-4G是属于内核态,1-3G是用户态。关于用户级页表和内核级页表这里就不过多介绍。

新增理解,如何看待地址空间和页表:

  •    1.地址空间是进程能够看到的资源窗口。*
    
  •    2. 页表决定,进程真正拥有资源的情况。*
    
  •    3.合理的地址空间+页表进行资源划分,我们就可以对一个进程所有资源进行分类。*
    

上述三点其实就是一点,资源划分的一个过程。那么一个进程内进行资源划分的细节又是如何呢?

首先,我们知道虚拟地址空间大小为4G,就相当于有2^32个地址,在页表中就应该有2^32个条目来记录每个地址。这样一来,我们发现就算是每个条目只有4个属性,那么页表的空间大小就至少为16G(样例数据)。

实际上,页表是没有这样大的内存空间。那么真正的页表是如何实现的呢?

在整个过程中,我们也需要知道操作系统是如何对物理内存进行管理的。

  •    1.对物理内存进行描述的是物理页(结构体),在物理内存中被称之页框。*
    
  •    2.在磁盘的可执行文件中,是以4kb为单位进行划分,每个4kb大小的区域叫做页帧。*
    
  •    3.物理内存和磁盘的基本单位都是被精心设置的,磁盘传输数据是以4kb为基本单位。*
    

页帧(page)

内核把物理页作为内存管理的基本单位. 尽管处理器的最小可寻址单位通常是字, 但是, 内存管理单元MMU通常以页为单位进行处理. 因此,从虚拟内存的上来看,页就是最小单位。

物理页的结构体--struct page结构

struct page {

//结构表示系统中的每个物理页.出于节省内存的考虑,struct page中使用了大量的联合体union.

  •    union*
    
  •    {*
    
  •            struct address_space *mapping; *
    
  •            void *s_mem; *
    
  •    }*
    

.......................................

  •    //内存属性--4kb*
    

}

如果想更加深入了解相关知识,点击:https://zhuanlan.zhihu.com/p/551685158

伙伴系统

关于伙伴系统,这里有一篇文章:https://zhuanlan.zhihu.com/p/565062881

通过上面的知识归纳后,那么虚拟地址--->页表--->物理内存整个过程真正是如实现的呢?我们明白了物理内存的基本单位是4kb,那么通过页表传输数据时,数据大小肯定不会超过4kb。具体实现如下:

   * 1.虚拟地址空间的基本单位为2^32,系统将它分成2^10,2^10,2^12。前查找2^10比特位索引页目录,再通过页目录找到指定的页表,再从页表中找到指定的页框的起始物理地址+虚拟地址低12位作为页表的偏移量。 *
  •    2.有可能我们只使用一个页目录和一个页表,当不需要其他页表的时候,换句话说就当地址没有与页面目录创建映射关系的时候,那么操作系统就可以不创建页表,这样就大大的减少了内存消耗。*
    

虚拟地址实质就是一种编址方式

在之前讲过进程,进程的实质:

  •    内核数据结构 +进程对应的代码和数据 *
    

关于线程,线程更像是进程的一个镜像,有许多相识点,但不完全一样。线程的本质:

  •   ** 进程内的一个执行流 *
    

如何理解进线程是程内的一个执行流?

    ●*在创建pcb时,不单独创建虚拟内存,页表,不拷贝对应的数据等;这个pcb只单独创建,并且与父进程指向同一个虚拟内存。让父进程来给它分配资源,即是线程。*
  •    *○*这就好像父进程是房主,线程是租客,虚拟内存是房间。房主给分配房间资源,租客在这个房间也可以干自己的事情,这个房间是足够大,可以容纳多个租客。*
    

因为我们可以通过虚拟地址空间+页表方式对进程资源划分,线程的执行力度,一定会比之前进程的执行力度要细。

这句话表明的意思就是说,因为pcb是能对mm_struct起到组织的作用,而线程又好比是进程中的一个小部分,那么进程执行时可以去调度线程,进程就好比是一个圈,线程就是圈里的组成部分。

现在大概知道,线程执行力度更细。那么线程是不是也应该被操作系统所管理呢,既然被管理就需要被描述,与pcb一样,大佬一定会为线程也专门设计一个数据结构表示线程对象,TCB。在Windows上,就是这样实现的。

这样的实现有好处是便于用户使用的,所以winds是面向大众的,但Linux是面向企业的。

因为执行时,有大量数据(id,状态,优先级,上下文,栈...)被调度,单独从线程调用角度,线程和进程有很多地方是重叠的。所以Linux工程师在设计Linux的线程时,并没有专诚给它设计对应的数据结构。而是直接复用PCB,用PCB来表示linux内部“线程”的数据结构。

根据上面知识,现在再度理解* *进程内的一个执行流

    *线程在进程内部运行,线程在进程的地址空间内运行,拥有进程的一部分内容。*

升华对进程与线程的理解

重新理解进程,在之前我们理解到的进程是内核数据结构+进程对应的代码和数据。但是理解还是不够全面,今天,通过内核视角。进程是什么:

    ●*承担分配系统资源的基本实体*

如何理解这句话呢?之前的理解好比是静止的,"死的",不同的数据块组成。但是现在的理解,更像是一个动态的过程,从创建pcb开始,pcb-->虚拟地址空间-->页表-->物理内存。当程序运行时,这个过程的统一目的就是为了执行分配资源的任务。

在Linux中,什么叫线程呢?

   ●*线程是CPU调度的基本单位*

在CPU调用进程时,真实情况是只关注task_struct,不会去区分进程和线程。例如执行一个程序时,先执行进程(这里的进程并是完整的,只用于区分第一个task_struct与后task_struct)task_struct,进程中的函数是有由线程(task_struct)执行,CPU统一关注task_struct来执行程序。所以线程其实就是CPU的基本单位。

话说回来,今日学习的进程与之前学习的进程概念冲突吗?

  *  *●*是不冲突的,之前学的进程,承担系统资源的基本实体,只不过内部只有一个执行流,今日理解的是多个执行流。总结,一个进程内部可以有多个执行流。*

之前的进程的含义与现在理解进程的含义是不一样的,相比之下,之前讲的进程其实就是现在理解的一个分支。对于进程的含义来说,今日理解的“进程”-(线程)的范围变小了与之前相比,在CPU眼中存在的不是进程,统一识别task_struct--轻量级进程。

在最后总结几点

  *  1.Linux内核中有没有真正意义的线程呢?没有的。Linux是用进程PCB来模拟线程的,是一种完全属于自己的一套线程方案*
  •    2.站在CPU的视角,每一个PCB,都可以称之为叫做轻量级进程(pcb可称之轻量级线程,但不能称之为进程,进程所包括更广)*
    
  •    3.Linux线程是CPU调度的基本单位,而进程是承担分配系统资源的基本单位*
    
  •    4.进程用来整体申请资源,线程用来伸手向进程要资源(例如在一个工作组中,组长向公司申请资源,组员可以向组长申请资源)*
    
  •    5.Linux中没有真正意义的线程,只有线程概念,但没有描述线程(未生成线程数据结构)*
    
  •    6.好处是什么?简单,维护成本大大降低--可靠高效! 具体好处如下:*
    

线程的优点

   * 1.创建一个新线程的代价要比创建一个新进程小得多 *
  •    **2.**与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多*
    
  •            ○进程:切换页表&&虚拟地址空间&&切换PCB &&上下文切换*
    
  •            ○线程:切换PCB &&上下文切换*
    
  •            ●线程切换cache不用太更新,但是进程切换,全部更新*
    
  •                    在我们之前学习冯诺依曼体系时,数据的传输是内存->CPU(高速缓存(cashe)->寄存器),内存的数据传输到CPU的寄存器的时候,热点数据是被保存在高速缓存中,便于寄存器的操作处理(当访问寄存器访问莫一行数据的时候,那么一段的数据都被保存在cashe,因为当你访问这一行数据的时候,那么大多数时候这一行联系的上下文数据都会被访问)。*
    
                      *所以要少很多实质是:少的是保存在cashe中的热点数据,频繁切换需要换的是cache里的热点数据。*
    

  •    3.线程占用的资源要比进程少很多 *
    
  •    4.能充分利用多处理器的可并行数量 *
    
  •    5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务 *
    
  •    6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现*
    
  •            应用:CPU,加密,解密,算法等* ;
    
              例如:文件进行打包压缩,利用多线程可以将一个大文件拆分成多份小文件进行打包压缩。
    
  •    7.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作*
    
  •            应用:访问外设,磁盘,显示器,网路;下载文件,上传文件*
    
  •            例如:在迅雷上下载文件,我们可以同时下载多个文件*
    

**线程的缺点 **

性能损失

    *●一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 *

健壮性降低

    *●编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。*  

     *●解释性代码如下:*
#include <iostream>
#include <pthread.h>
#include <cassert>
#include <unistd.h>
using namespace std;

void* start_routine(void* args)
{

    //一个线程出问题, 会影响其他线程吗? 肯定会的,健壮性或者鲁棒性较差
    //进程信号,是整体发给进程的,那么就会影响到各个线程
    string name =static_cast<const char*>(args);//安全的强制类型转换,编译时做检查
    while(true)
    {
        cout<< " new thread create pthread succes ,name :"<< args <<endl;
        sleep(1);
        int* p =nullptr;

        //向0空间写入0,再解引用,OS认为是无效之举,报错
        *p = 0;

    }
}

int main()
{
    pthread_t id;
    int n = pthread_create(&id,nullptr,start_routine,(void*)"pthread one");
    assert(n == 0);
    //(void*)n;

    while(true)
    {
        cout<< "main thread create pthread succes, name: "<<id <<endl ;
        sleep(1);
    }
    return 0;
}

缺乏访问控制

    *●进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。  *

编程难度提高

    *●编写与调试一个多线程程序比单线程程序困难得多* 

**线程异常 **

** ***●单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃 *

    *●线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该 进程内的所有线程也就随即退出 *

线程用途

** ***●合理的使用多线程,能提高CPU密集型程序的执行效率 *

  •    ●合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是 多线程运行的一种表现)*
    

**Linux进程VS线程 **

进程是资源分配的基本单位

线程是调度的基本单位

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

    ■线程ID

    ■一组寄存器

    ■栈

    ■errno

    ■信号屏蔽字 调度优先级

进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

    ■文件描述符表

    ■每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)

    ■当前工作目录

    ■用户id和组id

进程和线程的关系如下图:

Linux线程控制

为了让大家容易理解进程和线程,这里举一个例子

    在一个家庭中,父亲母亲工作挣钱,孩子读书,老人休闲娱乐。在这个家庭中每个人都扮演着不同的角色,但是目的都只有一个,让这个家庭变得更加美好。这里家庭就相当于进程,每个角色都相当于线程。

创建线程
接下来我就通过代码来证明,因为如果不是一个多线程,那么一个程序是不能同时有两个死循环的。pthread_create函数如下,后续在慢慢对它参数进行深究。

POSIX线程库

    ■与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的

    ■要使用这些函数库,要通过引入头文

    ■链接这些线程函数库时要使用编译器命令的“-lpthread”选项

//头文件

   #include <pthread.h>

//pthread_create函数

   int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                       void *(*start_routine) (void *), void *arg);

//库文件-pthread

   Compile and link with -pthread.
  • thread:输出型参数,用于获取新创建的线程tid
  • attr:设置线程属性,通常置NULL
  • start_routine:线程的入口函数,是一个函数指针。
  • arg:作为实参,通过线程入口函数传递给线程。
  • 返回值: 成功返回0。 失败返回errno,它的大小>0

错误检查:

    ■传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。

    ■pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返 回值返回

    ■pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通 过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小

测试代码如下

#include <iostream>
#include <cstdio>
#include <cassert>
#include <pthread.h>
#include <unistd.h>

using namespace std;

// 新线程
void *thread_routine(void *args)
{
    const char *name = (const char *)args;
    while (true)
    {
        cout << "我是新线程, 我正在运行....."<< endl;
        sleep(1);
    }
}

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

    // 主线程
    while (true)
    {
        cout << "我是主线程, 我正在运行....... " << endl;
        sleep(1);
    }

    return 0;
}

当我们运行是我们发现编译错误

[hongxin@VM-8-2-centos 2023-4-12]$ make
g++ -o mythread mythread.cc
/tmp/cc8bJgmZ.o: In function main': mythread.cc:(.text+0x5b): undefined reference to pthread_create'
collect2: error: ld returned 1 exit status
make: *** [mythread] Error 1

写程序时需要注意:

    ●*操作系统只认识线程,而用户也只认识线程。但是在Linux中只有这个概念却没有对线程进行描述,那么就造成了他们只认识task_struct,线程只是从用户的角度去理解的,Linux中无法直接创建线程的系统调用接口,而只能给用户提供创建轻量级进程(*task_struct*)的接口。*
  •    *●所以*创建线程pthread_create函数,其实并不是系统调用接口,而是库提供的。在编译时是不能直接通过,首先这个-pthread**库是原生库,是与Linux系统同时在的,这个库就肯定在磁盘中。因此Linux系统给我们在用户层封装了一套接口*,*所以在编译的时就需要链接那个一个库。*
    

我们这里在mekefile中编译时加上-lpthread,makefile文件内容如下:

mythread:mythread.cc
g++ -o $@ $^ -lpthread

.PHONY:clean
clean:
rm -f mythread

编译后,我们可以在终端中输入ldd mythread,查看该文件是否包含了其他库。

查看后,我们发现在lib64文件下是包含了libpthread.so.0文件库,在通过 ls /lib64/libpthread.* -al就可以查看,libpthread文件中使用的动态库和静态库。

所以在用户和操作系统之间有一个libpthread-2.17.so线程库, 该库是Linux下默认携带的这个的原生线程库。

当一切都准备就绪后,我们make一下,观察是不是正如我们猜想的意义。结果如下:

[hongxin@VM-8-2-centos 2023-4-12]$ ./mythread
我是主线程, 我正在运行.......
我是新线程, 我正在运行.....
我是主线程, 我正在运行.......
我是新线程, 我正在运行.....
我是主线程, 我正在运行.......
我是新线程, 我正在运行.....
我是新线程, 我正在运行.....
我是主线程, 我正在运行.......
我是新线程, 我正在运行.....
我是主线程, 我正在运行.......

**进程ID和线程ID **

为了更好的观察这两个执行流,通过脚本语言调用两个进程观察。脚本语言如下
ps ajx |head -1 && ps ajx | grep mythread
通过运行时,我们发现两个执行流只有一个进程,进程PID为30112,当我们把该进程杀死后,我们发现两个执行流都被杀死了。

如果我们想看两个执行流时,那么可以通过ps -aL查看。

LWP--Light Weighted Process(轻量级进程的唯一标识符)

    ■在Linux中,目前的线程实现是Native POSIX Thread Libaray,简称NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct结构体)。

    ■没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用getpid函数时返回相同的进程ID,如何解决上述问题呢?

    ■Linux内核引入了线程组的概念。

struct task_struct

{

     ...

    pid_t pid; pid_t tgid;

    ...

    struct task_struct *group_leader;

    ... struct list_head thread_group;

    ...

};

    ■多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct) 与之对应。进程描述符结构体中的pid,表面上看对应的是进程ID,其实不然,它对应的是线程ID;进程描述符中的tgid,含义是Thread Group ID,该值对应的是用户层面的进程ID

现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量。 如何查看一个线程的ID呢?

ps命令中的-L选项,会显示如下信息:

    ■LWP:线程ID,既gettid()系统调用的返回值。

    ■NLWP:线程组内线程的个数

又回到查看线程ID,我们发现当PID与LWP一样时,该线程未主线程,相反为新线程。不管是新线程还是主线程他们的PID都一样,说明都在一个进程中执行。而在CPU调度时,CPU是以LWP为唯一标识符表示特定的执行流。所以不一样的LWP就证明了,在Linux中CPU调度的基本单位是线程。

[hongxin@VM-8-2-centos 2023-4-12]$ ps -aL
PID LWP TTY TIME CMD
1399 1399 pts/39 00:00:00 mythread //主线程
1399 1400 pts/39 00:00:00 mythread //子线程

当只有一个线程的时候,PID=LWP的主线程实质就是我们以前讲的进程,所以说以前的进程是线程的一个分支。

线程创建的操作

在上面的代码的基础上,我们通过创建函数fun,和全局变量来证明:

    ●*线程一旦创建,机会所有资源都是被线程共享的*

观察后发现,fun函数可以通过调用,全局变量也能被通过进行操作。代码如下,已注释,就不过多讲解:

#include <iostream>
#include <cstdio>
#include <cassert>
#include <pthread.h>
#include <unistd.h>

using namespace std;

//全局函数fun,两个执行流都调用
std::string fun()
{
    return "独立方法";
}

//地址一样,用的同一个地址空间
//证明全局资源是共享
int g_val = 0;

// 新线程
void *thread_routine(void *args)
{
    //通过接受*args打印便可以证明
    const char *name = (const char *)args;
    while (true)
    {
          cout << "我是新线程, 我正在运行! name: " << name << " : "<< fun()  << " : " << g_val++ << " &g_val : " << &g_val << endl;
        sleep(1);
    }
    
}

int main()
{
    //typedef unsigned long int --tid就是一个无符号常整数
    //其意义是线程地址
    pthread_t tid;

    //void *arg是线程入口函数传递到新线程
    int n = pthread_create(&tid, NULL, thread_routine, (void *)"thread one");
    assert(0 == n);
    (void)n;

    // 主线程
    while (true)
    {
        //将tip写入tidbuffer
        char tidbuffer[64];
        //目的是将一个无符号常整数已地址的形式保存在tidbuffer缓冲区中,便于打印出来
        snprintf(tidbuffer,sizeof(tidbuffer),"0x%X",tid);

         cout << "我是主线程, 我正在运行!, 我创建出来的线程的tid: " << tidbuffer << " : " << g_val << " &g_val : " << &g_val << endl;
        sleep(1);
    }

    fun();
    return 0;
}

当大部分资源共享后,那些资源是被线程私有的呢?

  *  1.pcb属性私有*
  •    2.线程的私有上下文结构*
    
  •    3.独立的栈结构*
    

在创建时,我们也已经知道了调用的是-pthread库,使用的是该库中的函数pthread_create,在该库是调用的Linux操作系统下轻量级进程接口,那这个轻量级进程接口又是什么?通过man手册查看clone:

    #include <sched.h>

   int clone(int (*fn)(void *), void *child_stack,
              int flags, void *arg, ...
              /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

   long clone(unsigned long flags, void *child_stack,
              void *ptid, void *ctid,
              struct pt_regs *regs);
  • fn为函数指针,此指针指向一个函数体,即想要创建进程的静态程序(我们知道进程的4要素,这个就是指向程序的指针,就是所谓的“剧本", );
  • child_stack为给子进程分配系统堆栈的指针(在linux下系统堆栈空间是2页面,就是8K的内存,其中在这块内存中,低地址上放入了值,这个值就是进程控制块task_struct的值);
  • arg就是传给子进程的参数一般为(0);
  • flags为要复制资源的标志,描述你需要从父进程继承那些资源(是资源复制还是共享,在这里设置参数: - 下面是flags可以取的值

clone和fork的区别:

  • clone和fork的调用方式很不相同,clone调用需要传入一个函数,该函数在子进程中执行。
  • clone和fork最大不同在于clone不再复制父进程的栈空间,而是自己创建一个新的。 (void *child_stack,)也就是第二个参数,需要分配栈指针的空间大小,所以它不再是继承或者复制,而是全新的创造。

vfork与fork的区别:

  • vfork创建进程,其内存空间是共享的,全局变量等同时可以被父子进程修改。(轻量级线程的概念)
  • fork则相反(推荐用)

创建一批线程

代码如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cassert>
#include <vector>

using namespace std;

void* statu_routine(void* args)
{
    std::string name= static_cast<const char*>(args);
    while(true)
    {
        cout<<" new thread create pthread succes ,name :"<< name <<endl; 
        sleep(1);
    }
}

int main()
{
    //创建一批线程
    vector<pthread_t> tids;
#define NUM 10
    for(int i=0; i<NUM;++i)
    {
        pthread_t tid;
        //char namebuffer[64];
        //snprintf(namebuffer,sizeof(namebuffer),"%s:%d","thread",i);

        int n =pthread_create(&tid,nullptr,statu_routine,namebuffer);

        //不sleep时,namebuffer传输的是缓存区的起始地址,当我们创建一个进程的时候(随机创建)
        //线程创建也是不确定谁先运行的。所以不睡眠时,就有可能使主线先运行 for运行完后创建线程 将地址传过去
        //换句话说,主线程先创建然后在跑完后再穿的地址
        //睡眠一秒,其目的是为了判断先后执行顺序
        //sleep(1);
        assert(n ==0);
    }

    while(true)
    {
        cout<< "main thread create pthread succes, name: "<<endl ;
        sleep(1);
    }

}

可以通过ps -aL | grep mythread观察所创建的11个线程,但是我们发现每个线程的名字都是一样的,那么我们改变线程的名如何做呢?

[hongxin@VM-8-2-centos 2023-4-14]$ ps -aL | grep mythread

32050 32050 pts/33 00:00:00 mythread
32050 32051 pts/33 00:00:00 mythread
32050 32052 pts/33 00:00:00 mythread
32050 32053 pts/33 00:00:00 mythread
32050 32054 pts/33 00:00:00 mythread
32050 32055 pts/33 00:00:00 mythread
32050 32056 pts/33 00:00:00 mythread
32050 32057 pts/33 00:00:00 mythread
32050 32058 pts/33 00:00:00 mythread
32050 32059 pts/33 00:00:00 mythread
32050 32060 pts/33 00:00:00 mythread

也可以直接用如下脚本语言直接看创建线程的数量
ps -aL | grep mythread | wc -l
11
创建一个缓冲区,然后用snprintf通过循环写入进缓冲区中,再将缓冲去已参数的形式传入到statu_routine函数中并打印出来。但是这种方法是错误的,在打印时会出现线程名有相同的情况,如下所示:

[hongxin@VM-8-2-centos 2023-4-14]$ ./mythread

new thread create pthread succes ,name :thread:9
new thread create pthread succes ,name :thread:8
new thread create pthread succes ,name :thread:6
new thread create pthread succes ,name :thread:6
new thread create pthread succes ,name :thread:6
new thread create pthread succes ,name :thread:7

如何解决呢?我们通过sleep,能解决,其原因如下:

   * 1.namebuffer传输的是缓存区的起始地址,每个namebuffer都是共享的*
  •    2.  当我们创建一个进程的时候,我们知道父子进程随机执行的,线程创建同样的,也是不确定谁先运行的。*
    
  •    3.所以不睡眠时,就有可能使主线先运行 for运行完后创建线程将地址传过去换句话说,主线程先创建然后在跑完后再穿的地址*
    
  •    4.睡眠一秒,其目的是为了判断先后执行顺序*
    

这样的方法也没有从根本上解决问题,根本问题就是因为资源共享导致主线程的td传入到了新线程中。解决问题所以要将(新线程)的资源,变成私有资源。

    ■*每次创建线程时都会向堆区申请空间,所以每次地址都是改变的。*
  •    *■*传参到statu_routine函数中,statu_routine在接受参数的时候会发生拷贝,所以该函数的td地址,与mian中的线程不产生关系,这样每个函数都不会出现共享buffer的情况,这份数据都属于新线程私有的。*
    

代码如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cassert>
#include <vector>

using namespace std;

//创建全局变量
class ThreaDtae{
public:
    pthread_t tid;
    char namebuffer[64]; 
};

void* statu_routine(void* args)
{
    ThreaDtae *td= static_cast<ThreaDtae*>(args);
    int cnt=10;

    while(cnt--)
    {
        cout<<" new thread create pthread succes ,name :"<< td->namebuffer<<"cnt:"<< cnt <<endl; 
        sleep(1);
    }

    delete td;
    return nullptr;
}

int main()
{
    //创建一批线程
    //便于清理维护
    std::vector<ThreaDtae> threads;
#define NUM 10
    for(int i=0; i<NUM;++i)
    {
        ThreaDtae *td = new ThreaDtae();
        snprintf(td->namebuffer,sizeof(td->namebuffer),"%s:%d","thread",i+1);
        pthread_create(&td->tid,nullptr,statu_routine,td);

        threads.push_back(td);
    }

    for(auto &iter : threads)
    {
        cout << "create thread: " << iter->namebuffer << " : " << iter->tid << " suceesss" << endl;
    }
        
    while(true)
    {
        cout<< "main thread create pthread succes: "<<endl ;
        sleep(1);
    }

}

上述代码是一次性创建多个线程,那么start_routine, 现在是被几个线程执行呢?相信大家一下肯定一下就会回答上,不就是10个嘛。那就再来两个问题。

问题一:start_routine函数的状态?

    *■重入状态,所以start_routine函数是可重入函数*

问题二:start_routine函数是如何做到,执行其他方法时不被影响?

    ■在函数内定义的变量,都叫做局部变量,具有临时性 -- 今天依旧适用 -- 在多线程情况下, 也没有问题 -- 其实每一个线程都有自己独立的栈结构!

**线程终止 **

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

    1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。

    2. 线程可以调用pthread_ exit终止自己。

    3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

*pthread_exit函数 *

功能:线程终止

原型

    void pthread_exit(void *value_ptr);

参数

    value_ptr:value_ptr不要指向一个局部变量。

返回值:

    无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel函数

功能:取消一个执行中的线程

原型

    int pthread_cancel(pthread_t thread);

参数

    thread:线程ID

返回值:成功返回0;失败返回错误码

线程等待

为什么需要线程等待?

    ■*如果不等待,会造成类似于僵尸进程的问题--内存泄漏*

线程等待的作用

    *■获取新线程的退出信息*
  •    ■回收欣线程对应的PCB等内核资源,防止内存泄漏*
    

pthread_join函数

功能:等待线程结束

原型

    int pthread_join(pthread_t thread, void **value_ptr);

参数

    thread:线程ID

    value_ptr:它指向一个指针,后者指向线程的返回值

返回值:成功返回0;失败返回错误码

深度理解参数(void **value_ptr)

void *value_ptr(二级指针)实质是通过pthread_join函数去读取pthread库中保存的statu_routine的返回值,然后用指针变量ret(取地址)来接受statu_routine的返回值指向的地址,最后再对指向的地址的进行解引用变成整型,简单来说就是将pthread_join返回值写入到ret中。详细过程如图:

c++--多线程的操作

在任何语言中,在Linux实现多线程,必须要用pthread库;c++11的多线程,在Linux环境下,其本质也是对pthread库的封装。

因为c++11是对pthread库的封装,所以如下代码是可跨平台的。

#include <iostream>
#include <thread>
#include <unistd.h>

void thread_run()
{
    while(true)
    {
        std::cout<<"我是 新线程..."<<std::endl;
        sleep(1);
    }
}

int main()
{
    std::thread t1(thread_run);

    while(true)
    {
        std::cout<<"我是 主线程..."<<std::endl;
        sleep(1);
    }

    t1.join();

    return 0;

}

分离线程

简单来讲,线程分离就是当线程被设置为分离状态后,线程结束时,它的资源会被系统自动的回收,而不再需要在其它线程中对其进行pthread_join() 操作。

   * joinable* :*系统会保存线程资源(栈、ID、退出状态等)直到线程退出并且被其他线程 join*。

在我们使用默认属性创建一个线程的时候,线程是 joinable 的。 joinable 状态的线程,必须在另一个线程中使用 pthread_join() 等待其结束,如果一个 joinable 的线程在结束后,没有使用 pthread_join() 进行操作,这个线程就会变成”僵尸线程”。每个僵尸线程都会消耗一些系统资源,当有太多的僵尸线程的时候,可能会导致创建线程失败。所以我们需要将线程分离出来。

关于线程分离其最主要的一个点就是接口的使用:

功能:一个线程的属性设置为 detachd 的状态,让系统来回收它的资源

原型

   int pthread_detach(pthread_t thread);

参数

   thread 线程ID

返回值

    成功返回0,失败返回错误码

关于线程ID,分离新线程那就调用的就是当前线程的ID,所以就需要一个获取线程ID的接口:

   pthread_t pthread_self(void); 

joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

测试代码如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cassert>
#include <vector>
#include <cstring>

using namespace std;

std::string changeID(const pthread_t &thread_id)
{
    char tid[128];
    snprintf(tid,sizeof(tid),"0x%x",thread_id);
    return tid;
}

void* statu_routine (void* args)
{
    string threadname = static_cast<const char*> (args);
    pthread_detach(pthread_self());//设置分离

    int cnt =5;
    while(cnt--)
    {
        std::cout<< threadname<< "runing...:" << changeID(pthread_self())<<std::endl;
        sleep(1); 
    }

    return nullptr;
}

int main()
{   
    pthread_t tid;
    pthread_create(&tid,nullptr,statu_routine,(void*)"thread 1");

    std::string main_id = changeID(pthread_self());
    std::cout<<  "main thread runing... new thread id:" << changeID(tid)<<"main thread id"<<main_id<<endl;

    //一个线程默认是joinable的,如果设置了分离状态,不能够进行等待!
    // int n= pthread_join(tid,nullptr);
    // std::cout<< "result" << n << ":" <<strerror(n) << endl;

    while(true)
    {
        std::cout<<  "main thread runing... new thread id:" << changeID(tid)<<"main thread id"<<main_id<<endl;
        sleep(1);
    }

    return 0;
}

代码解释:当新线程进行分离后,新线程5秒后打印完自动退出并回收资源。主线程继续运行。

[hongxin@VM-8-2-centos 2023-4-15_1]$ ./mythread

0main thread id0x48f97740thread 1runing...:0x47f02700

main thread runing... new thread id:0x47f02700main thread id0x48f97740
thread 1runing...:0x47f02700
main thread runing... new thread id:0x47f02700main thread id0x48f97740
thread 1runing...:0x47f02700
main thread runing... new thread id:0x47f02700main thread id0x48f97740
thread 1runing...:0x47f02700
main thread runing... new thread id:0x47f02700main thread id0x48f97740
thread 1runing...:0x47f02700
main thread runing... new thread id:0x47f02700main thread id0x48f97740
main thread runing... new thread id:0x47f02700main thread id0x48f97740

..........................

**线程ID及进程地址空间布局 **

一直以来,不管是主线程还是新线程都有thread id,相信大家也一定好奇thread id到底什么?先描述,再组织也是经常提到,那么有什么联系呢?

创建线程时,我们也不止创建一个,也创建过多个。创建多个线程是不是也需要被管理,被管理肯定先要被描述。

所以thread id是什么?是线程的属性。关于线程的属性有哪些呢?

之前又提及创建线程并不是操作系统直接提供的接口,那么对线程的描述和管理是在原生线程库还是在内核中呢?

关于这些问题,我们先梳理一下用户创建线程的大致流程:

用户 (调用接口) --> pthread库-描述,组织(调用轻量级进程接口) --> 虚拟空间(共享区)

            -->页表(映射) --> 物理内存

那么线程id究竟是什么呢?

    ■*其实就是pthread库中描述当前线程的地址,用户能通过改地址能够访问到该用户级线程。*

对于上面流程,还需要特别讲解的是,用户级线程与内核轻量级进程的比=1 : 1

    *1.首先我们知道,库其实就是磁盘文件,这里就是libpthread.so*

-rw-r--r-- 1 root root 222 May 18 2022 libpthread.so
* 2.动态库的加载,是将磁盘文件加载到内存,然后通过映射到虚拟内存的共享区中*

  •    3.在共享区中,资源共享,用户级线程与内核轻量级进程形成1:1的对接。*
    
  •    4.在库中对线程的管理,就相当于在共享区中进行管理。*
    

所以对线程的描述和管理是在原生线程库中。

相信仔细的童鞋还观察到了,线程栈和线程局部储存

    *■在动态库中,每个线程都有TCB结构,每个结构都有相应的属性        *
  •    ■每个轻量级线程中的线程栈都是在共享区中创建的,与内核的主线程栈是不同的。*
    
  •    ■添加__thread,可以将一个内置类型设置为线程局部存储。如果想给线程定义私有属性,不放在栈上,不向new/malloc。运行时天然具有一块空间,__thread就相当于设置私有属性。两个线程中同一变量的地址发生改变,对其操作互不影响。*
    

线程的封装

注释掺杂在代码中,mythread.hpp文件中代码如下:

//header olny 开源代码
#pragma once 
#include <iostream>
#include <string>
#include <cstring>
#include <cassert>
#include <functional>
#include  <pthread.h>

class Thread;

//上下文,当成一个结构体
class Context
{
public:
    Thread *this_;
    void* args_;
public:
    Context()
    :this_(nullptr)
    ,args_(nullptr)
    {}

    ~Context()
    {}
};

class Thread
{
public:
    // using func_t = std::function<void*(void*)>;
    typedef std::function<void*(void*)> func_t;
    const int num = 1024;
public:
    Thread(func_t func, void *args = nullptr, int number = 0)
    : func_(func)
    , args_(args)
    {
        // name_ = "thread-";
        // name_ += std::to_string(number);

        char buffer[num];
        snprintf(buffer, sizeof buffer, "thread-%d", number);
        name_ = buffer;

         // 异常 == if: 意料之外用异常或者if判断
        // assert: 意料之中用assert
        Context *ctx = new Context();
        ctx->this_ = this;
        ctx->args_ = args_;
        int n = pthread_create(&tid_, nullptr, start_routine, ctx); //TODO
        assert(n == 0); //编译debug的方式发布的时候存在,release方式发布,assert就不存在了,n就是一个定义了,但是没有被使用的变量
        // 在有些编译器下会有warning
        (void)n;
    }
    // 在类内创建线程,想让线程执行对应的方法,需要将方法设置成为static
    static void *start_routine(void *args) //类内成员,有缺省参数!
    {
        Context *ctx = static_cast<Context *>(args);
        void *ret = ctx->this_->run(ctx->args_);
        delete ctx;
        return ret;
        // 静态方法不能调用成员方法或者成员变量
    }

    void join()
    {
        int n = pthread_join(tid_, nullptr);
        assert(n == 0);
        (void)n;
    }

    void *run(void *args)
    {
        return func_(args);
    }

    ~Thread()
    {
        //do nothing
    }

private:
   std::string name_;
    func_t func_;
    void *args_;

    pthread_t tid_;
    
};

[ 作者 : includeevey

📃 [ 日期 : 2023 / 4 / 1

[ 代码 : 卿洪欣 (hong-xin-qing) - Gitee.com
📜 [ 声明 : 到这里就该说再见了,若本文有错误和不准确之处,恳望读者批评指正!
有则改之无则加勉!若认为文章写的不错,一键三连加关注!
————————————————


本文转载自: https://blog.csdn.net/includeevey/article/details/130080461
版权归原作者 includeevey 所有, 如有侵权,请联系我们删除。

“Linux--进程多线程(上)”的评论:

还没有评论