Linux 多线程(线程概念、线程控制部分)

Linux 多线程

文章目录

  • Linux 多线程
    • Linux线程概念
      • 1.回顾一下之前所认知的进程
      • 2.什么是线程
        • 总结:
      • 3.线程的优点
      • 4.线程的缺点
      • 5.线程异常
      • 6.线程用途
    • Linux进程与线程
      • 1.进程和线程的不同
      • 2.进程的多个线程共享同一地址空间
      • 3.进程和线程的关系
    • Linux线程控制
      • 1.Linux没有真正意义上的线程,因此也没用真正意义上有关线程的系统调用。
      • 2.POSIX线程库
      • 3.创建线程
      • 4.线程ID及进程地址空间布局
      • 5.线程等待
      • 6.线程终止
      • 7.分离线程

Linux线程概念

1.回顾一下之前所认知的进程

1.一个进程有自己对应的PCB、对应的地址空间、页表(虚拟地址映射到物理地址)、物理内存。

以前的认知:进程 = 内核数据结构 + 进程对应的代码和数据

Linux 多线程(线程概念、线程控制部分)_第1张图片

​ 相信上面这张图大家一定不陌生了,原本我们创建进程时,OS需要给我们task_struct, 对应的虚拟地址空间,页表,物理内存。

​ 当我们创建一个子进程时,我们同样需要task_struct, 对应的虚拟地址空间和页表。

​ 由于进程具有独立性,进程间通信是非常麻烦的。

2.什么是线程

那么什么是线程? 教材里说:线程是进程内的一个执行流。相信大家看到都一脸懵逼,难以理解。

教材的说法太过于宏观,太抽象,难以理解,因此这次讲多线程我将以LInux为准。(不同的操作系统具体的实现会不同)

如何看待虚拟内存:虚拟内存决定了进程能够看到的"资源"。(进程是封闭房子中的人,而虚拟内存是窗户,人只能通过窗户看到外面)

以前创建子进程:PCB 虚拟内存 页表 都搞一份

选择创建线程:只创建PCB,都指向同一个进程地址空间。

我们之前接触的进程内部都只有一个task_struct,即该进程内部只有一个执行流,是单执行流进程。因此内部有多个task_struct的进程有多个执行流,叫做多执行流进程。

现在的进程认知:

Linux 多线程(线程概念、线程控制部分)_第2张图片
线程这样有什么好处呢?

​ 首先是只需要创建PCB,消耗的资源大大减少,其二,一个进程内的线程都指向同一块虚拟内存,线程间通信的代价就非常小了。

接下来让我们一起回答三个问题

1.什么叫进程? 内核视角:承担系统分配资源的基本实体

2.在Linux中,什么叫做线程? CPU调度的基本单位。(cpu调度时只关注task_struct)

3.如何看待我们之前学习进程时,对应的进程概念。

​ 进程是承担分配系统资源的基本实体,只不过内部只有一个执行流(以前),现在一个进程内可以有多个执行流。

大致了解线程后,让我们来思考一个问题?

如果我们OS真的要专门设计“线程”概念,OS需不需要来管理这个线程呢?

​ 如何管理? ---------- 先描述再组织, 我们一定需要为线程设计专门的数据结构表示线程对象(TCB),windows采用的就是这种方法,同样因此,Windows的代码实现就显得复杂多了。

​ 执行调度时,我们需要线程的(id、状态、优先级、上下文、栈)。 所以单纯从线程调度的角度,线程和进程有很多的地方是重叠的,所以Linux工程师选择了直接复用PCB,用PCB来表示Linux内部的“线程”。

总结:

1Linux内核中没有真正意义上的线程,Linux使用进程PCB来模拟线程的

2.站在CPU的视角,每一个PCB,都可以称之为轻量级进程。

3.Linux线程是cpu调度的基本单位,而进程是承担分配系统资源的基本单位

4.进程用来整体申请资源,线程向进程要资源。

5.Linux无法直接提供创建线程的系统调用接口,而只能给我们提供创建轻量级进程的接口。

6.Linux选择复用有什么好处? 简单,维护成本大大降低,更加可靠和高效。

3.线程的优点

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

4.线程的缺点

  • 性能损失

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

  • 健壮性降低

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

  • 缺乏访问控制

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

  • 编程难度提高

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

5.线程异常

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

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

6.线程用途

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

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

Linux进程与线程

1.进程和线程的不同

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

  • 线程是调度的基本单位

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

    • 线程ID

    • 一组寄存器

    • errno

    • 信号屏蔽字

    • 调度优先级

2.进程的多个线程共享同一地址空间

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

  • 文件描述符表

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

  • 当前工作目录

  • 用户id和组id

3.进程和线程的关系

Linux 多线程(线程概念、线程控制部分)_第3张图片

之前我们学习的进程都是只有一个线程执行流的进程。

Linux线程控制

1.Linux没有真正意义上的线程,因此也没用真正意义上有关线程的系统调用。

​ Linux可以提供创建轻量级进程的系统调用接口,即创建一个进程,但是共享空间,其中典型的代表之一就是vfork()函数。

vfork()函数的功能是创建子进程,但是父子进程会共享空间。

pid_t vfork(void);

vfork的返回值和fork()的相同:

  • 给父进程返回子进程的pid
  • 给子进程返回0
#include
#include
#include
#include
using namespace std;

int gval = 100; //定义一个全局变量,用来测试vfork后父子进程是否同用一块空间。

int main()
{
    pid_t id = vfork();

    if(id == 0)
    {
        gval = 200;
        printf("我是子进程 -- pid:%d,ppid:%d,gval:%d\n",getpid(),getppid(),gval);
        exit(0);
    }
    
    printf("我是父进程 -- pid:%d,ppid:%d,gval:%d\n",getpid(),getppid(),gval);

    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第4张图片

可以看到打印出来的gval的值是相同的,父子进程是共享空间的。

​ 在Linux中,从内核角度看,并没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统在用户层提供了原生线程库pthread原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口

2.POSIX线程库

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

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

  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项,因为原生线程库pthread是动态链接的。

3.创建线程

创建一个新的线程的函数叫做pthread_create( ).

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

参数:

  • thread:返回线程ID

  • attr:设置线程的属性,attr为NULL表示使用默认属性

  • start_routine:是个函数地址,线程启动后要执行的函数

  • arg:传给线程启动函数的参数

返回值:

  • 成功返回0;失败返回错误码

错误检查:

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

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

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

接下来让我们用主线程来创建一个新线程:

#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    char* msg = static_cast(args);
     while(true)
    {
        cout<< msg <<":我是新线程,正在运行!"<

运行后,我们可以看到主线程和新线程交替进行打印,至于其中为什么出现了一行混合在一起的情况在线程安全部分再予以解答。Linux 多线程(线程概念、线程控制部分)_第5张图片

当我们用ps axj 指令查看当前进程信息,我们只能看到一个进程信息,因为我们的两个线程都属于该进程。

ps axj | head -1 && ps axj | grep mythread | greap -v grep

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nBWt8eo3-1679837420781)(C:\Users\2119869498\AppData\Roaming\Typora\typora-user-images\image-20230326191320254.png)]

使用ps -aL 就可以看到轻量级进程了。

Linux 多线程(线程概念、线程控制部分)_第6张图片

LWP(Light Weight Process)就是轻量级进程的ID

从上图我们可以看到主线程的PID和LWP相同.

注意: 在Linux中,应用层的线程与内核的LWP是一一对应的,实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以使用PID和LWP并无区别。

接下来让我们用主线程创建一批新线程:

#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的pid:%d,ppid:%d\n",msg,getpid(),getppid());
        sleep(1);
    }
}
int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }

    //主线程
    while(true)
    {
        printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
        sleep(2);
    }
    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第7张图片

我们成功创建了5个线程,他们的pid和ppid都相同,因为都属于同一个进程。

Linux 多线程(线程概念、线程控制部分)_第8张图片

用ps -aL 命令,我们看到了6个轻量级进程。

4.线程ID及进程地址空间布局

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。

  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:

pthread_t pthread_self(void);

获取线程ID的两种方式:1.输出型参数,pthread_create的第一个参数。 2.pthread_self,获取调用该函数的线程的ID.

#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    sleep(1);
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:%lld\n",msg,getpid(),getppid(),pthread_self());
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }

    for(int i=0;i<5;++i)
    {
        printf("主线程中通过输出型参数存储的tid[%d]:%lld\n",i,tid[i]);

    }

    //主线程
    while(true)
    {
        printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
        sleep(2);
    }
    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第9张图片

注意: 用pthread_self函数获得的线程ID与内核的LWP的值是不相等的,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,它们之间是一对一的关系。

pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

tid实际上就是struct_pthread的起始地址.

Linux 多线程(线程概念、线程控制部分)_第10张图片

5.线程等待

线程和进程一样,都是需要等待的,如果主线程不对新线程进行等待,那么新线程运行结束时的资源也不会被回收,成了类似僵尸进程一样的情况,导致了内存的泄漏。

线程等待函数:pthread_join( );

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

参数:

  • thread:被等待线程的ID。
  • retval:线程退出时的退出码信息。

返回值:

  • 线程等待成功返回0,失败返回错误码。

调用该函数的线程将挂起等待,直到TID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的退出码是不同的。

Linux 多线程(线程概念、线程控制部分)_第11张图片

总结:

  • thread线程通过return返回retval所指向的里面存放的是thread线程函数的返回值
  • thread线程被别的线程调用pthread_cancel异常终止掉retval所指向的单元里存放的是常数PTHREAD_CANCELED。
  • thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数
  • 如果对thread线程的终止状态不感兴趣,可以传nullptr给retval参数。

注意点:

  1. PTHREAD_CANCELED是头文件里定义的一个宏,它的值本质是-1。
  2. pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正确。(进程中线程出异常直接整个进程就挂掉了)
  3. 因为上一条的原因,我们无法像进程等待一样,可以通过wait函数或是waitpid函数的输出型参数status,获取到退出进程的退出码、退出信号以及core dump标志。

6.线程终止

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

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
  2. 线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
  • return退出
#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    sleep(1);
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }
    sleep(2);
    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第12张图片

​ 在主线程创建完线程后我们直接返回,因为进程内的线程共享空间,所以当主线程返回时OS将对应的资源回收时,其他进程的资源也没了,所以也会退出。

  • pthread_exit函数的使用
 void pthread_exit(void *retval);
  • retval 线程退出时的退出码信息

注意:

  1. 无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
  2. pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

接下来我们将退出码设置为2233

#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    sleep(1);
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
        sleep(1);
        pthread_exit((void*)2233);
    }
}

int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }
    void* ret;

    for(int i =0;i<5;++i)
    {
        pthread_join(tid[i],&ret);
        printf("线程%d :exit_code: %d\n",i,(long long)ret);
    }
    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第13张图片

注意点:exit()函数是终止整个进程,任意线程调用都会导致进程终止。

  • pthread_cancel函数

线程是可以取消的,我们可以通过其取消一个执行中的线程

int pthread_cancel(pthread_t thread);

参数

  • thread:线程ID

返回值:

  • 成功返回0;失败返回错误码

线程是可以取消自己的,但我们一般不这样子,一般用于一个线程取消另一个线程。比如:主线程取消新线程。

#include
#include
#include
#include
using namespace std;

//新线程
void* routine(void* args)
{
    sleep(1);
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
        sleep(1);
        pthread_exit((void*)2233);
    }
}

int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }
    void* ret;
    for(int i=0;i<4;++i)
    {
        pthread_cancel(tid[i]);
    }
    for(int i =0;i<5;++i)
    {
        pthread_join(tid[i],&ret);
        printf("线程%d :exit_code: %d\n",i,(long long)ret);
    }
    return 0;
}

通过pthread_calcel取消的进程退出码都是PTHREAD_CANCELED(-1)。

Linux 多线程(线程概念、线程控制部分)_第14张图片

通过新线程取消主线程会如何呢?

#include
#include
#include
#include

using namespace std;

pthread_t minTID;
//新线程
void* routine(void* args)
{
    sleep(1);
    char* msg = static_cast(args);
     while(true)
    {
        printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
        sleep(1);
        pthread_cancel(minTID);
    }
}

int main()
{
    pthread_t tid[5];
    minTID = pthread_self();
    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }

    void* ret;
    for(int i =0;i<5;++i)
    {
        pthread_join(tid[i],&ret);
        printf("线程%d :exit_code: %d\n",i,(long long)ret);
    }
    return 0;
}

运行代码的同时,我们用监控脚本来进行观察:

$ while :; do ps -aL | head -1 && ps -aL | grep mythread | grep -v grep ; echo "###########" ;sleep 1;done
  1. 当采用这种取消方式时,主线程和各个新线程之间的地位是对等的,取消一个线程,其他线程也是能够跑完的,只不过主线程不再执行后续代码了。
  2. 我们一般都是用主线程去控制新线程,这才符合我们对线程控制的基本逻辑,虽然新线程可以取消主线程,但是并不推荐该做法。
    Linux 多线程(线程概念、线程控制部分)_第15张图片

主线程被取消后右边显示< defunct >

Linux 多线程(线程概念、线程控制部分)_第16张图片

7.分离线程

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

int pthread_detach(pthread_self());

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

#include
#include
#include
#include

using namespace std;

//新线程
void* routine(void* args)
{
    char* msg = static_cast(args);
    int count = 3;
     while(count>0)
    {
        printf("%s: 我是新线程,我的pid:%d,ppid:%d\n",msg,getpid(),getppid());
        sleep(1);
        --count;
    }
}

int main()
{
    pthread_t tid[5];

    for(int i=0 ;i<5;++i)
    {
        char* buff = new char[64];

        snprintf(buff,sizeof(buff),"thread%d",i+1);  
        int n = pthread_create(tid+i,nullptr,routine,buff);
        assert(0==n);
        (void)n;
    }
    for(int i=0;i<5;++i)
    {
        pthread_detach(tid[i]);   //分离线程
    }
    //主线程
    while(true)
    {
        printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
        sleep(2);
    }
    return 0;
}

Linux 多线程(线程概念、线程控制部分)_第17张图片

新线程退出时,OS直接回收了资源,不再需要主线程join。

你可能感兴趣的:(Linux学习,linux,运维)