当前位置: 首页 > news >正文

Linux——多线程

目录

1. Linux线程概念

1.1 什么是线程

1.2 分页式存储管理

1.2.1 虚拟地址和页表的由来

1.2.2 物理内存管理

1.2.3 二级页表

1.3 线程的优点 

1.4 线程的缺点

1.5 线程异常

1.6 线程用途

2. Linux进程VS线程

2.1 进程与线程

2.2 进程的多个线程共享

2.3 进程和线程的关系

3. Linux线程控制 

3.1 POSIX线程库

3.2 线程创建 

3.3 线程等待

3.4 线程终止

 3.5 分离线程

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


1. Linux线程概念

1.1 什么是线程

1、线程是“一个进程内部的控制序列”。

2、线程在进程内部运行,本质是在进程地址空间内运行。

3、一切进程至少都有一个执行线程。

4、在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。

5、透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执行流,就形 成了线程执行流。

需要明确的是,一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的。

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

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

此时我们创建的实际上就是四个线程:

1、其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的“线程是进程内部的一个执行分支”,线程就是轻量级进程。

2、同时我们也可以看出,线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

该如何重新理解之前的进程?

下面用蓝色方框框起来的内容,我们将这个整体叫做进程。

因此,所谓的进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号等等,合起来称之为一个进程。

现在我们应该站在内核角度来理解进程:承担分配系统资源的基本实体,叫做进程。 

换言之,当我们创建进程时是创建一个task_struct、创建地址空间、维护页表,然后在物理内存当中开辟空间、构建映射,打开进程默认打开的相关文件、注册信号对应的处理方案等等。

而我们之前接触到的进程都只有一个task_struct,也就是该进程内部只有一个执行流,即单执行流进程,反之,内部有多个执行流的进程叫做多执行流进程

在Linux中,站在CPU的角度,能否识别当前调度的task_struct是进程还是线程?

答案是不能,也不需要了,因为CPU只关心一个一个的独立执行流。无论进程内部只有一个执行流还是有多个执行流,CPU都是以task_struct为单位进行调度的。

单执行流进程被调度:

多执行流进程被调度:

因此,CPU看到的虽说还是task_struct,但已经比传统的进程要更轻量化了。 

Linux下并不存在真正的多线程!而是用进程模拟的!

1、操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多,当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细。

2、在Linux看来,描述线程的控制块和描述进程的控制块是类似的,因此Linux并没有重新为线程设计数据结构,而是直接复用了进程控制块,所以我们说Linux中的所有执行流都叫做轻量级进程。 

既然在Linux没有真正意义的线程,那么也就绝对没有真正意义上的线程相关的系统调用!

Linux可以提供创建轻量级进程的接口,也就是创建进程,共享空间,其中最典型的代表就是vfork函数。 

vfork函数的功能就是创建子进程,但是父子共享空间,vfork函数原型如下:

pid_t vfork(void);

vfork函数的返回值与fork函数的返回值相同:

给父进程返回子进程的PID。
给子进程返回0。

只不过vfork函数创建出来的子进程与其父进程共享地址空间,例如在下面的代码中,父进程使用vfork函数创建子进程,子进程将全局变量g_val由100改为了200,父进程休眠3秒后再读取到全局变量g_val的值。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 100;
int main()
{pid_t id = vfork();if (id == 0){//childg_val = 200;printf("child:PID:%d, PPID:%d, g_val:%d\n", getpid(), getppid(), g_val);exit(0);}//fathersleep(3);printf("father:PID:%d, PPID:%d, g_val:%d\n", getpid(), getppid(), g_val);return 0;
}

1.2 分页式存储管理

1.2.1 虚拟地址和页表的由来

思考⼀下,如果在没有虚拟内存和分页机制的情况下,每⼀个⽤户程序在物理内存上所对应的空间必须是连续的,如下图:

因为每⼀个程序的代码、数据长度都是不⼀样的,按照这样的映射方式,物理内存将会被分割成各种离散的、大小不同的块。经过⼀段运行时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。 

我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。

此时就产生了虚拟内存和页表:

把物理内存按照⼀个固定的长度的页框进行分割,每个页框包含⼀个物理页。⼀个页的大小等于页框的大小。 

大多数32位体系结构⽀持 4KB 的页,而64位体系结 构⼀般会支持 8KB 的页。

有了这种机制,CPU便并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。

所谓的虚拟地址空间,是操作系统为每⼀个正在执行的进程分配的⼀个逻辑地址,在32位机上,其范围从0~4GB-1。

操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系,也就是页表,这张表上记录了每⼀对页和页框的映射关系,能让CPU间接的访问物理内存地址。

1.2.2 物理内存管理

假设⼀个可用的物理内存有4GB 的空间。按照⼀个页框的大小4KB进行划分, 4GB 的空间就是 4GB/4KB = 1048576 个页框。

有这么多的物理页,操作系统肯定是要将其管理起来的,那么就要先描述,再组织。

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

/* include/linux/mm_types.h */
struct page {/* 原⼦标志,有些情况下会异步更新 */ 
unsigned long flags;union {struct {/* 换出⻚列表,例如由zone->lru_lock保护的active_list */ struct list_head lru;/* 如果最低为为0,则指向inode * address_space,或为NULL * 如果⻚映射为匿名内存,最低为置位 * ⽽且该指针指向anon_vma对象 */struct address_space* mapping;/* 在映射内的偏移量 */ pgoff_t index;/** 由映射私有,不透明数据 * 如果设置了PagePrivate,通常⽤于buffer_heads * 如果设置了PageSwapCache,则⽤于swp_entry_t * 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶 */unsigned long private;};struct { /* slab, slob and slub */union {struct list_head slab_list; /* uses lru */struct { /* Partial pages */struct page* next;
#ifdef CONFIG_64BITint pages; /* Nr of pages left */int pobjects; /* Approximate count */
#elseshort int pages;short int pobjects;
#endif};};struct kmem_cache* slab_cache; /* not slob *//* Double-word boundary */void* freelist; /* first free object */union {void* s_mem; /* slab: first object */unsigned long counters; /* SLUB */struct { /* SLUB */unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */ unsigned objects : 15;unsigned frozen : 1;};};
};...};union {/* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜
索*/ atomic_t _mapcount;unsigned int page_type;unsigned int active; /* SLAB */int units; /* SLOB */};...
#if defined(WANT_PAGE_VIRTUAL)/* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */ void* virtual;
#endif /* WANT_PAGE_VIRTUAL */...
}

在操作系统中,这些page结构体实际上是被一个数组管理起来的,即struct page mem[];那么这也就意味着每一个page都会有一个下标,那么每一个page的起始物理地址我们就直接知道了,那么具体的物理地址就可以通过起始物理地址+页内偏移量得到。

那么此时我们需要理解一下申请内存的时候OS到底在做什么? 

实际上就是查数组,找到没有被使用的page,然后根据数组下标索引,找到物理页框地址,把对应空间分配给需求方。 

1.2.3 二级页表

以32位平台为例,在32位平台下一共有2^32个地址,也就意味着有2^32个地址需要被映射。

如果我们所谓的页表就只是单纯的一张表,那么这张表就需要建立2^32个虚拟地址和物理地址之间的映射关系,即这张表一共有2^32个映射表项。

每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,比如我们所说的用户级页表和内核级页表,实际就是通过权限进行区分的。

每个应表项中存储一个物理地址和一个虚拟地址就需要8个字节,考虑到还需要包含权限相关的各种信息,这里每一个表项就按10个字节计算。
这里一共有232个表项,也就意味着存储这张页表我们需要用232 * 10个字节,也就是40GB。

而在32位平台下我们的内存可能一共就只有4GB,也就是说我们根本无法存储这样的一张页表。 

因此所谓的页表并不是单纯的一张表,这样内存根本存不下。

1、选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表。

2、再选择虚拟地址的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址。

3、最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据。

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

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

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

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

单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

修改常量字符串为什么会触发段错误?

当我们要修改一个字符串常量时,虚拟地址必须经过页表映射找到对应的物理内存,而在查表过程中发现其权限是只读的,此时你要对其进行修改就会在MMU内部触发硬件错误,操作系统在识别到是哪一个进程导致的之后,就会给该进程发送信号对其进行终止。

说到这里呢,我们来总结几个点,进一步理解线程:

1、线程对资源的划分,本质上是对虚拟地址空间的划分,获得一定范围的合法虚拟地址,再本质,其实就是在划分页表。

2、线程对资源的共享,本质上是虚拟地址空间的共享,再本质,是对页表条目的共享;所以大家要意识到,其实虚拟地址空间本质是一种资源。虚拟地址就是资源的代表,一个进程拥有的虚拟地址越多,就证明它占有的资源越多。

3、我们之前学过的mm_struct和vm_area_struct其实就是进程资源的统计数据和整体数据。

1.3 线程的优点 

1、线程占用的资源要比进程少很多。

2、能充分利用多处理器的可并行数量。

3、创建⼀个新线程的代价要比创建⼀个新进程小得多。

4、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。

原因有二:其一,线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的;其二,进程切换时,TLB(快表)和Cache失效,下次运行,需要重新缓存,这也是主要原因。

1.4 线程的缺点

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

1.5 线程异常

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

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

1.6 线程用途

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

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

2. Linux进程VS线程

2.1 进程与线程

进程是承担分配系统资源的基本实体,线程是调度的基本单位。

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

1、线程ID。

2、一组寄存器。(存储每个线程的上下文信息)

3、栈。(每个线程都有临时的数据,需要压栈出栈)

4、errno。(C语言提供的全局变量,每个线程都有自己的)

5、信号屏蔽字。

6、调度优先级。

2.2 进程的多个线程共享

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

1、如果定义一个函数,在各线程中都可以调用。

2、如果定义一个全局变量,在各线程中都可以访问到。

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

1、文件描述符表。(进程打开一个文件后,其他线程也能够看到)

2、每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)

3、当前工作目录。(cwd)

4、用户ID和组ID。

2.3 进程和线程的关系

在此之前我们接触到的都是具有一个线程执行流的进程,即单线程进程。

3. Linux线程控制 

3.1 POSIX线程库

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

1、应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方帮我们提供的。

2、要使用这些函数库,要通过引入头文件<pthread.h>。

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

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

3.2 线程创建 

pthread_create函数的函数原型如下:

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

参数说明:

1、thread:获取创建成功的线程ID,该参数是一个输出型参数。
2、attr:用于设置创建线程的属性,传入NULL表示使用默认属性。
3、start_routine:该参数是一个函数指针(返回值是void*,参数是void*),表示线程例程(新线程),即线程启动后要执行的函数。
4、arg:传给线程例程(新线程)的参数。

返回值说明:

线程创建成功返回0,失败返回错误码。

让主线程创建一个新线程

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做主线程。

1、主线程是产生其他子线程的线程。

2、通常主线程必须最后完成某些执行操作,比如各种关闭动作。

下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。

#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
void* routine(void* arg)
{char* msg=(char*)arg;while(1){printf("我是新线程:%s\n",msg);sleep(1);}}
int main()
{pthread_t tid;pthread_create(&tid,NULL,routine,(void*)"thread-1");while(1){printf("我是主线程\n");sleep(2);}return 0;
}

 

这里通过运行结果我们可以看到,主线程和新线程分别在执行自己的代码。 

我们也可以通过监控查看:

当我们用ps axj命令查看当前进程的信息时,虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的

使用ps -aL命令,可以显示当前的轻量级进程。

1、默认情况下,不带-L,看到的就是一个个的进程。

2、带-L就可以查看到每个进程内的多个轻量级进程。

这里大家可以看到两个轻量级进程,第一个是主线程(即PID与LWP相同),第二个是我们创建的新线程。

这里的LWP就是Light Weight Process,即轻量级进程。CPU调度的时候实际上看的是LWP,因为线程是调度的基本单位。当前进程的时间片将会平均分配给两个轻量级进程(线程)。

可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。

为了进一步证明这两个线程是属于同一个进程的,我们可以让主线程和新线程在执行打印操作时,将自己的PID和PPID也进行打印。

#include<stdio.h>
#include<unistd.h>
#include<pthread.h>
void* routine(void* arg)
{char* msg=(char*)arg;while(1){printf("我是新线程:%s,pid:%d,ppid:%d\n",msg,getpid(),getppid());sleep(1);}}
int main()
{pthread_t tid;pthread_create(&tid,NULL,routine,(void*)"thread-1");while(1){printf("我是主线程,PID:%d,PPID:%d\n",getpid(),getppid());sleep(2);}return 0;
}

可以看到主线程和新线程的PID和PPID是一样的,也就是说主线程和新线程虽然是两个执行流,但它们仍然属于同一个进程。 

让主线程创建多个新线程

上面是让主线程创建一个新线程,下面我们让主线程一次性创建五个新线程,并让创建的每一个新线程都去执行Routine函数,也就是说routine函数会被重复进入,即该函数是会被重入的,即为可重入函数。

#include<stdio.h>
#include<cstdlib>
#include<sys/types.h>
#include<unistd.h>
#include<pthread.h>
void* routine(void* arg)
{char* msg=(char*)arg;while(1){printf("我是新线程:%s,pid:%d,ppid:%d\n",msg,getpid(),getppid());sleep(1);}}
int main()
{pthread_t tid[5];for(int i=1;i<=5;i++){char* buffer=(char*)malloc(64);sprintf(buffer,"thread %d",i);pthread_create(&tid[i],nullptr,routine,buffer);}while(1){printf("我是主线程,PID:%d,PPID:%d\n",getpid(),getppid());sleep(2);}return 0;
}

因为主线程和五个新线程都属于同一个进程,所以它们的PID和PPID也都是一样的。

此时我们再用ps -aL命令查看,就会看到六个轻量级进程。 

获取线程ID 

常见获取线程ID的方式有两种:

1、创建线程时通过输出型参数获得。
2、通过调用pthread_self函数获得。

pthread_self函数的函数原型如下:

pthread_t pthread_self(void);

调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。

例如,下面代码中在每一个新线程被创建后,主线程都将通过输出型参数获取到的线程ID进行打印,此后主线程和新线程又通过调用pthread_self函数获取到自身的线程ID进行打印。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{char *msg = (char *)arg;while (1){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);}
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}while (1){printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());sleep(2);}return 0;
}

这里大家注意看我画框的两个例子,可以说明两种方法可以获得相同的线程ID。

并且大家可以看到消息打印出来是混杂的,这是因为每个线程都同时向显示器文件进行写入,本质上来说显示器文件属于共享资源,当多线程同时向显示器文件写入时,就会导致原子性错误,说白了就是一个还没写完,另一个又来写了,于是打印出来就是混杂的,想要解决这个问题,后面我们会学到“锁”,可以通过加锁的方式解决这个问题。

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

3.3 线程等待

首先需要明确的是,一个线程被创建出来,这个线程就如同进程一般,也是需要被等待的。

如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的,所以线程需要被等待,如果不等待会产生类似于“僵尸进程”的问题,也就是内存泄漏。

等待线程的函数叫做pthread_join

pthread_join函数的函数原型如下:

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

参数说明:

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

返回值说明:

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


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

总结一下:

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

例如,在下面的代码中我们先不关心线程的退出信息,直接将pthread_join函数的第二次参数设置为nullptr,等待线程后打印该线程的编号以及线程ID。

#include <stdio.h>
#include <cstdlib>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
void *routine(void *arg)
{char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程:%s,pid:%d,ppid:%d,tid:0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;}return nullptr;
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], nullptr, routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}printf("我是主线程,PID:%d,PPID:%d,tid:0x%lx\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){void *ret = nullptr;pthread_join(tid[i], &ret);//printf("thread %d,quitcode:%lld\n", i, (long long int)ret);}return 0;
}

我们运行代码后,可以通过监控脚本查看线程的数量变化;

下面我们再来看看如何获取线程退出时的退出码,为了便于查看,我们这里将线程退出时的退出码设置为某个特殊的值,并在成功等待线程后将该线程的退出码进行输出。

#include <stdio.h>
#include <cstdlib>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
void *routine(void *arg)
{char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程:%s,pid:%d,ppid:%d,tid:0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;}return (void*)2025;
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], nullptr, routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}printf("我是主线程,PID:%d,PPID:%d,tid:0x%lx\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){void *ret = nullptr;pthread_join(tid[i], &ret);printf("thread %d,quitcode:%lld\n", i, (long long int)ret);}return 0;
}

这里代码运行大家可以看到,退出码就是我们在routine函数中设置的返回值。

注意: pthread_join函数默认是以阻塞的方式进行线程等待的。

为什么线程退出时只能拿到线程的退出码?而没有退出信号?

这里大家需要区分进程和线程,在进程等待的时候,进程可能异常退出,所以我们可以获得退出信号,也有必要获得;但是在线程等待时,线程如果异常退出,那么整个进程都会崩,pthread_join也就没有意义了,换句话说,线程能被等待,肯定是正常退出的,所以pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正确。 

3.4 线程终止

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

1、从线程函数return。

2、线程可以自己调用pthread_exit函数终止自己。

3、一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

对于这三种方法,我们最推荐用return退出,这个也最常用。

return退出

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{char *msg = (char *)arg;while (1){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);}
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());return 0;
}

这里我们看不见新线程打印的信息,因为主线程退出导致了整个进程退出了。

pthread_exit函数 

pthread_exit函数的功能就是终止线程,pthread_exit函数的函数原型如下:

void pthread_exit(void *retval);

 参数说明:retval:线程退出时的退出码信息。

说明一下:

1、该函数无返回值,跟进程一样,线程结束的时候无法返回它的调用者(自身)。

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

例如,在下面代码中,我们使用pthread_exit函数终止线程,并将线程的退出码设置为2025。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;}pthread_exit((void *)2025);
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){void *ret = nullptr;pthread_join(tid[i], &ret);printf("thread %d,quitcode:%lld\n", i, (long long int)ret);}return 0;
}

这里大家可以发现,其实pthread_exit函数和return的用法基本是一模一样的。

注意: exit函数的作用是终止进程,任何一个线程调用exit函数也代表的是整个进程终止。

pthread_cancel函数 

线程是可以被取消的,我们可以使用pthread_cancel函数取消某一个线程,pthread_cancel函数的函数原型如下:

int pthread_cancel(pthread_t thread);

参数说明:thread:被取消线程的ID。

返回值说明:线程取消成功返回0,失败返回错误码。

线程是可以取消自己的,取消成功的线程的退出码一般是-1。

例如在下面的代码中,我们让线程执行一次打印操作后将自己取消;

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;pthread_cancel(pthread_self());}pthread_exit((void *)2025);
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){void *ret = nullptr;pthread_join(tid[i], &ret);printf("thread %d,quitcode:%lld\n", i, (long long int)ret);}return 0;
}

运行代码,可以看到每个线程执行一次打印操作后就退出了,其退出码不是我们设置的2025而是-1,因为我们是在线程执行pthread_exit函数前将线程取消的。

虽然线程可以自己取消自己,但一般不这样做,我们往往是用于一个线程取消另一个线程,比如主线程取消新线程。 

例如,在下面代码中,我们在创建五个线程后立刻又将0、1、2、3号线程取消。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;}pthread_exit((void *)2025);
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}pthread_cancel(tid[0]);pthread_cancel(tid[1]);pthread_cancel(tid[2]);pthread_cancel(tid[3]);printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){void *ret = nullptr;pthread_join(tid[i], &ret);printf("thread %d,quitcode:%lld\n", i, (long long int)ret);}return 0;
}

通过监控,我们可以发现只剩两个线程了。

还可以看到,只有4号线程的退出码是我们设置的返回值,其余4个线程的退出码均为-1。

 3.5 分离线程

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

分离线程的函数叫做pthread_detach

pthread_detach函数的函数原型如下:

int pthread_detach(pthread_t thread);

参数说明:

thread:被分离线程的ID。

返回值说明:

线程分离成功返回0,失败返回错误码。

例如,下面我们创建五个新线程后让这五个新线程将自己进行分离,那么此后主线程就不需要在对这五个新线程进行join了。

#include <cstdio>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>void *Routine(void *arg)
{pthread_detach(pthread_self());char *msg = (char *)arg;int cnt = 5;while (cnt){printf("我是新线程%s...pid: %d, ppid: %d, tid: 0x%lx\n", msg, getpid(), getppid(), pthread_self());sleep(1);cnt--;}pthread_exit((void *)2025);
}
int main()
{pthread_t tid[5];for (int i = 0; i < 5; i++){char *buffer = (char *)malloc(64);sprintf(buffer, "thread %d", i);pthread_create(&tid[i], NULL, Routine, buffer);printf("%s tid is 0x%lx\n", buffer, tid[i]);}while (1){printf("我是主线程...pid: %d, ppid: %d, tid: 0x%lx\n", getpid(), getppid(), pthread_self());sleep(1);}return 0;
}

同样我们打开监控脚本,可以查看线程数量的变化。

大家可以看到最后就剩主线程了,新线程自己把自己分离出去,运行完就被系统自动回收了,在主线程里我并没有写等待函数。

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

1、pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中,该线程ID和内核中的LWP不是一回事。

2、内核中的LWP属于进程调度的范畴,因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

我们采用的线程库实际上是一个动态库,进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时该进程内的所有线程都是能看到这个动态库的。

我们说每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。

从上图可以看出,线程的概念是在库中维护的,Linux中所有的线程都在库中;那么在库内部就会有很多创建好的线程,于是OS就需要将他们管理起来,那么如何管理呢?先描述,再组织!

每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。

线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程。 

所以大家再看前面代码中打印出来的线程ID是一个很大是数字,其实它本质上是一个虚拟地址。

所以说到这里,我们再来理解一下线程是如何被创建的?

1、在库中创建线程控制的管理块,如上图所示。

2、在内核中创建轻量级进程(OS调用系统调用自动完成,用户不关心)。

那么线程传参和返回值该如何理解呢?

首先我们来看传参的问题:

//把pd(就是线程控制块地址)作为ID,传递出去,所以上层拿到的就是⼀个虚拟地址,对应pthread_create函数中&tid*newthread = (pthread_t)pd;
//向线程tcb中设置未来要执⾏的⽅法的地址和参数 pd->start_routine = start_routine;pd->arg = arg;

下面展示一部分线程TCB属性内容:

/* Thread ID - which is also a 'is this thread descriptor (andtherefore stack) used' flag. */pid_t tid;/* Process ID - thread group ID in kernel speak. */pid_t pid;
/* The result of the thread function. */// 线程运⾏完毕,返回值就是void*, 最后的返回值就放在tcb中的该变量⾥⾯ // 所以我们⽤pthread_join获取线程退出信息的时候,就是读取该结构体 // 另外,要能理解线程执⾏流可以退出,但是tcb可以暂时保留,这句话 void *result;
// ⽤户指定的⽅法和参数 void *(*start_routine) (void *);void *arg;// 线程⾃⼰的栈和⼤⼩ void *stackblock;size_t stackblock_size;

大家注意void* result,这是线程控制块中是一个字段,我们在routine函数中设置的返回值,在新线程结束时就会被写到这个void* result字段中;所以我们所说的新线程结束实际上只是函数结束了,但是库中申请的线程管理块并没有释放,所以主线程要等待新线程,大家现在再来看上面代码中pthread_join(tid,&ret),这个tid就是运行完毕的线程对应控制块的起始虚拟地址,pthread_join函数就可以通过这个地址找到那个需要被释放的管理块,同时将void* result中的返回值通过二级指针带出来返回给上层。

最后关于线程分离,其实也很简单,在线程控制块内部有对应检查是否分离的字段。

//检测线程属性是否分离,这个很好理解 bool is_detached = IS_DETACHED(pd);

 

相关文章:

  • 电厂数据库未来趋势:时序数据库 + AI 驱动的自优化系统
  • 用 Rust 搭建一个优雅的多线程服务器:从零开始的详细指南
  • Linux 一键部署chrony时间服务器
  • Java中的包装类
  • Knife4j文档的会被全局异常处理器拦截的问题解决
  • 三个线程 a、b、c 并发运行,b,c 需要 a 线程的数据如何解决
  • Edu教育邮箱申请成功下号
  • SSTI模版注入
  • 【日撸 Java 三百行】Day 9(While语句)
  • 让模型具备“道生一,一生二,二生三,三生万物”的现实实用主义能力
  • SPL量化---SMA(算术移动平均)
  • LLM 推理加速:深度解析 Prefilling 与 Decoding 阶段的优化秘籍
  • 全球首套100米分辨率城市与农村居住区栅格数据(2000-2020)
  • Gradio launch() 方法所有参数说明
  • Missashe计网复习笔记(随时更新)
  • python连接sqllite数据库工具类
  • 运维体系架构规划
  • 执梦为楫,共启中医传承新篇
  • SpringAI框架中的RAG知识库检索与增强生成模型详解
  • RAG 技术详解:如何让大模型更 “懂” 知识库?
  • 上海发布首份直播电商行业自律公约,禁止虚假宣传、商业诋毁
  • 【社论】职业上新,勇于“尝新”
  • 赵作海因病离世,妻子李素兰希望过平静生活
  • 国家出口管制工作协调机制办公室部署开展打击战略矿产走私出口专项行动
  • 全国人大常委会启动食品安全法执法检查
  • 第一集|《刑警的日子》很生活,《执法者们》有班味