<Linux> 多线程

发布于:2024-10-13 ⋅ 阅读:(10) ⋅ 点赞:(0)

目录

一、Linux线程概念

1. 什么是线程

2. 线程与进程

3. 重谈地址空间 -4

二级页表 

4. 线程的优点

5. 线程的缺点

6. 线程异常

7. 线程用途

二、Linux进程VS线程

1. 进程和线程

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

3. 进程和线程的关系

 三、线程控制 

1. POSIX线程库

2. 线程创建 

3. ps -aL 

4. tid

5. 线程等待

6. 线程终止

7. 线程分离


一、Linux线程概念

1. 什么是线程

线程(Thread):线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。每个线程都拥有独立的栈和线程上下文,但共享进程中的代码段、数据段和其他资源。

进程(Process):进程是系统进行资源分配和调度的一个独立单元,是线程的直接管理者。

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是一个进程内部的控制序列”
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • Linux系统,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
  • 地址空间就是进程的资源窗口
  • 进程之间是独立的,父进程创建子进程,子进程也有自己的各种内核数据结构,内部数据与父进程高度相似(拷贝后再修改一点),但是根据页表映射到物理内存的位置是不同的
  • 进程是独立拷贝的一份进程地址空间,而线程是共享一部分的进程地址空间
  • 从CPU视角来看,原则上它不需要知道调度的是进程还是线程,它只有调度执行流的概念
  • 在Linux中,线程在进程“内部”执行,即线程在进程的地址空间内运行 (任何执行流要执行,都需要资源,而地址空间就是进程的资源窗口)。进程是独立拷贝的一份进程地址空间,线程是共享一部分进程地址空间
  • 线程的执行粒度要比进程要更细,因为代码量只是进程的一部分

理解:进程与线程可以类比社会中的家庭和家庭内的家人,家庭就是一个资源分配的实体,会为每一个家庭内部人员分配资源,例如为学生分配资源(钱、住所、饭等等,这些对应于进程地址空间的堆、栈、代码等资源)使其能够执行自己的执行流 --- 上学,家庭内的成员都执行着各自的任务(父母上班、祖父母退休锻炼身体、学生上课学习),每一个成员执行各自的执行流都是为了一个相同的目的,让生活过得更好,对应于进程与线程就是为了让CPU执行完自己的代码

所以理解一下:线程是OS调度的基本单位,对应一下上面的例子,人就是社会调度的基本单位(劳动力)

2. 线程与进程

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

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

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

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

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

 

重新定义进程与线程

  • 线程是操作系统调度的基本单位
  • 我们之前认为进程 = 内核数据结构 + 代码和数据(v1版本)现在给出新的定义,进程是承担分配资源的基本实体(v2,因为进程创建时,OS会为进程分配大量资源,而线程是进程内的执行流资源(执行流也被视为一种资源),线程创建只需要在进程内部创建task_struct结构体,并为其分配进程的一部分代码等资源。所以进程是承担分配资源的基本实体

线程比进程更加轻量化:

线程在执行,本质就是进程在执行、进程被调度,因为线程是进程的一个执行流分支,线程的整个生命周期都比进程轻量化

  1. 创建和释放更加轻量化(生死问题)。因为线程只需要创建PCB指向进程地址空间即可,而进程的创建需要很多操作,为其申请很多资源
  2. 切换更加轻量化(运行问题)。因为同一个进程内的线程在切换时不需要切换地址空间、页表,所以CPU中的一些寄存器不需要修改,更重要的是cache不需要修改(因为数据共享,是同一个进程内的数据),而进程切换就需要修改很多寄存器和cache(缓存数据由冷变热)

CPU有一个硬件缓存cache -- 缓存热数据,这是因为CPU觉得与内存交互的效率也很慢,所以出现了cache硬件缓存,它会缓存正在访问的代码的后50-100行代码,所以如果CPU继续访问后续的数据,这些数据就会被高效命中,所以cache这部分数据会被高频访问,从而提高CPU读取效率

cache默认大小(没有规定,大多不同): 

 

进程的时间片也是资源,也会被划分

        线程的时间片是分割的进程的时间片,线程不会单独申请时间片,因为如果一个进程有很多线程,每个线程都能再申请时间片,那么这无疑挤压了其他进程的运行时间,不合理。

        只有一个执行流时的进程,该执行流被称为主线程,其余创建的新的线程被称为新线程

怎么理解我们之前定义的进程?

进程 = 内核数据结构 + 代码和数据(v1),OS以进程为单位给进程分配资源,所谓的进程并不是通过task_struct来衡量的,除了task_struct之外,一个进程还要有进程地址空间、文件、信号等数据结构,这些资源合起来称之为一个进程。而现在我们应该站在内核角度来理解进程:承担分配系统资源的基本实体,叫做进程。

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

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

只有一个执行流时的进程,该执行流被称为主线程,其余创建的新的线程被称为新线程 

所以此后我们可将单PCB的进程与多PCB的进程中的线程,都称为一个执行流,所以在CPU来看,执行流 <= 进程,在Linux中的执行流 = 轻量级进程

Linux没有真正意义上的线程(其实是设计方案不同而已,并不能说Linux没有线程),而是用“进程”的内核数据结构模拟的线程

因为操作系统内存在大量进程,在线程概念出现后,一个进程会对应至少1个线程,进程需要知道它内部有多少线程,所以设计者依旧采用先描述线程,再组织起来线程,这就使得本就关系复杂的进程内部又会嵌套复杂的线程,例如要考虑线程创建、线程调度、线程终止、线程切换等概念,实际上线程的设计思路与进程几乎完全相似!

windows操作系统就是采用一套独立的数据结构组织设计的线程,即单独又组织了线程结构,而Linux在设计时,众多Linux开源项目的参与者并没有专门设计线程的数据结构,而是选择复用进程的数据结构的管理算法,因此线程的数量一定比进程的数量多,当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细,并且线程对比进程只是执行粒度细了一点,代码少了一点,设计思路与进程几乎相同,所以采用进程的数据结构模拟线程才是上策。

其实这两种方案都满足OS学科中的线程的概念,但在设计中,线程明显与进程高度相似,维护高度相似的代码成本会增高,所以复用才是高效的选择,可以使代码更加健壮。

理解:进程与线程可以类比社会中的家庭和家庭内的家人,家庭就是一个资源分配的实体,会为每一个家庭内部人员分配资源,例如为学生分配资源(钱、住所、饭等等,这些对应于进程地址空间的堆、栈、代码等资源)使其能够执行自己的执行流 --- 上学,家庭内的成员都执行着各自的任务(父母上班、祖父母退休锻炼身体、学生上课学习),每一个成员执行各自的执行流都是为了一个相同的目的,让生活过得更好,对应于进程与线程就是为了让CPU执行完自己的代码

所以理解一下:线程是OS调度的基本单位,对应一下上面的例子,人就是社会调度的基本单位(劳动力)

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

答案是不能,并且也不需要了,因为CPU只关心一个一个的独立执行流,线程才是操作系统调度的基本单位。无论进程内部只有一个执行流还是有多个执行流,CPU都是以线程为单位进行调度的。

 单进程或单执行流(只有主线程)进程被调度:

多进程或多执行流(主线程、新线程)进程被调度 :

进程是如何基于地址空间给多个执行流(线程)分配资源的? 

        进程所有的资源都是根据地址空间 + 页表映射到物理内存的,为线程分配资源本质就是分配地址空间范围,为其划分一部分空间供其使用。

        如果想让进程内所有线程都能访问同样的数据,那么就不划分该范围的数据。(大部分的线程,除了栈区,其他的地址空间都是共享的,代码区是各自独立的,当然代码区也有一些共享的,例如共享的函数)

那么进程如何划分代码给线程执行?

        我们的代码也有地址,所有的函数也有地址(函数指针指向函数的地址),这天然的就将代码进行了划分,所以我们将函数划分给线程,让线程执行不同的函数,这就实现了划分。

 原生线程库pthread

在Linux中,没有真正的线程,线程的实现是用进程模拟的,所以Linux中没有线程的系统调用接口,只有创建轻量化进程的系统调用clone,创建轻量级进程(LWP)的系统调用通常是通过clone()函数进行的。clone()系统调用是fork()系统调用的一个更通用、更灵活的版本,它允许子进程(在这里可以理解为轻量级进程)与父进程共享更多的资源,比如地址空间、文件描述符等。

轻量级进程(LWP)是Linux下与线程概念相关的实体,它们与普通进程的主要区别在于共享相同的地址空间和资源。由于共享资源,这些进程被认为是轻量级的。在Linux中,线程通常是通过轻量级进程(LWP)来实现的,每个线程对应一个LWP,由CPU进行调度。

所以对于用户来说,用户是需要OS直接提供线程创建、释放等接口函数的,所以用户层的程序员实现了原生线程库(第三方库),并将原生线程库安装在了Linux中,用户在使用时包含上pthread.h头文件就可以使用多线程

当程序调用pthread_create()函数创建新线程时,该函数内部实际上会调用clone()系统调用来创建一个新的LWP。这个新的LWP被用来实现新创建的线程,并与其他线程共享同一个进程的资源。因此,我们可以将pthread_create()创建的线程看做是在程序中创建的一种LWP。

在Linux中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是clone参数这么长的系统调用,因此系统为用户层提供了原生线程库pthread。

原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口。

因此对于我们来讲,在Linux下学习线程实际上就是学习在用户层模拟实现的这一套接口,而并非操作系统的接口。

3. 重谈地址空间 -4

虚拟地址具体是如何转化为物理地址的?

        这就使得我们不得不再来详细谈谈与进程地址空间绑定的页表结构 

二级页表 

问题引入:

        以32位平台为例,在32位平台下(32根地址线),32个0、1序列可以组成 2^32 个地址,也就意味着有 2^32 个地址需要在页表内被映射

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

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

        每个页表项中存储一个物理地址和一个虚拟地址就需要8个字节,考虑到还需要包含权限相关的各种信息,这里每一个表项就按10个字节计算。
        这里一共有 2^32 个表项,也就意味着存储这张页表我们需要用 2^32 * 10 个字节,也就是 40GB,而在32位平台下我们的内存可能一共就只有4GB,也就是说我们根本无法存储这样的一张页表。

因此所谓的页表并不是单纯的一张表,而是另有设计!

还是以32位平台为例,其页表的实际映射过程如下:

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

问:第三条说明我们根据映射只能找到一个字节的数据,可是我们所定义的变量、结构体大多都是4字节、8字节等大小的数据,那么它是如何拿到后面的数据?

答:这就是类型存在的原因!int a = 10; 表示a的类型是4字节,所以a在物理内存应该占据四个地址,而&a只拿到了一个地址!这是因为只能拿一个有代表性的地址最小的那一个字节的地址,读取时从该地址开始,读取类型的字节数,即可读取完毕!所以任何类型取地址都是只有一个地址!起始地址+ 类型 = 起始地址 + 偏移量

        类型是给CPU看的,CPU内置了一些命令可以辨别类型

相关说明:

  1. 物理内存实际是被划分成一个个4KB大小的页框的,而磁盘上的程序也是被划分成一个个4KB大小的页帧的,当内存和磁盘进行数据交换时也就是以4KB大小为单位进行加载和保存的。
  2. 4KB 实际上就是 2^12 个字节,也就是说一个页框中有 2^12 个字节,而访问内存的基本大小是1字节,因此一个页框中就有2^12个地址,这就是将12个比特位分配为偏移量的原因,所以从页框的起始地址处开始向后进行偏移,就可以找到物理内存中某一个指定的字节数据。

        这实际上就是我们所谓的二级页表,其中页目录项是一级页表,页表项是二级页表。任何一个进程都必须有页目录(可以不填页表项,但是不能没有)

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

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

CPU中有cr3寄存器存放页表地址,cr2寄存器存放引起缺页中断异常的虚拟地址,方便在缺页中断之后再找到原先位置

注意: 在Linux中,32位平台下用的是二级页表,而64位平台下用的是多级页表。

        所以缺页中断的发现就是CPU根据虚拟地址找页目录,再去找二级页表,发现没有页框的起始地址或还没建立映射关系,就会触发缺页中断,在内存开辟空间,将磁盘中数据加载到内存,再建立映射(可以说内存管理的基本单位就是4KB——页框)

4. 线程的优点

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

概念说明:

  • 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等。
  • IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。

5. 线程的缺点

  • 性能损失 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的一个线程出问题(除零错误、野指针、异常,会导致进程收到信号,信号的处理独立于整个执行流),整个进程就会挂掉(每个线程都会进行信号的默认动作),因为线程就代表进程
  • 缺乏访问控制 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高编写与调试一个多线程程序比单线程程序困难得多

6. 线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。因为线程就代表进程,进程崩溃,它的资源会被释放,其他线程的资源也会没有,所以其他线程也会崩溃
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

7. 线程用途

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

二、Linux进程VS线程

1. 进程和线程

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

线程共享进程数据(例如全局变量都会被共享),但也拥有自己的一部分数据:

  • 线程ID
  • 一组寄存器(上下文数据)
  • 栈 
  • errno
  • 信号屏蔽字
  • 调度优先级

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

        因此Text Segment(代码段)、Data Segment(数据段)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到

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

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程地址空间的资源对于线程来说绝大部分都是共享的,唯独线程上下文和独立栈线程私有的

内核中有没有很明确的线程的概念?

没有,因为线程是轻量级进程,是进程模拟的,所以没有专门的线程结构,OS只会给我们提供轻量级进程的系统调用clone。

但是用户需要线程的接口,所以在应用层、用户层,对轻量级进程接口进程了封装,为用户直接提供线程的接口 pthread 线程库(第三方库),几乎所有的Linux平台都默认自带 pthread 库,即在Linux中编写多线程代码需要使用第三方库 pthread 

3. 进程和线程的关系

 三、线程控制 

1. POSIX线程库

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

  • 应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方帮我们提供的。
  • 原生指的是大部分Linux系统都会默认带上该线程库。
  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
  • 要使用这些函数库,要通过引入头文件<pthread.h>。
  • 链接这些线程函数库时,要使用编译器命令的 “-lpthread” 选项,因为我们使用的是第三方库,所以必须带 -lpthread 选项为OS指定是哪一个库文件

错误检查:

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

2. 线程创建 

man pthread_create

参数说明:

  • thread:获取创建成功的线程ID,该参数是一个输出型参数,类型为 pthread_t

  • attr:用于设置创建线程的属性,传入NULL表示使用默认属性。
  • start_routine:该参数是一个函数指针,参数是void*,返回值是void*,表示线程例程,即线程启动后要执行的函数。创建线程后,新线程会转而执行函数指针指向的函数(main函数是主线程的函数入口,函数指针指向的函数是新线程的函数入口)
  • arg:传给线程例程的参数。

返回值说明:

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

C语言指针大小与平台位数有关, Linux中平台是64位,所以指针大小是8字节

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

  • 主线程是产生其他子线程的线程。
  • 通常主线程必须最后退出,完成某些执行操作,比如各种关闭动作,即等待子线程

示例:

问题:make在链接时出现错误! 

小知识:gcc、g++本来就认识C、C++库,所以在之前没使用第三方库时,我们不用手动指定名称,而除了操作系统提供的库(fork、wait等)、语言提供的库,这两种库,我们使用的其他的库gcc、g++一律都不认识,这些库都被认为是第三方库(例如公司的库、网上下载的库等),在Linux中安装完第三方库之后,在编译时都要使用 -l (小写L)指定我们使用的库的文件名称

解决:第三方库在gcc或g++编译时需要指定库在哪里 ,pthread 已经被安装在 Linux 头文件、库文件的路径下了,但是库文件很多,我们还要为OS指明,我们要使用的库文件是在库路径下的哪一个 

此时使用ldd指令查看动态库信息

ldd mythread

可以看到此时系统就找到了库文件(每个Linux都自带pthread库,且都已经安装在系统默认库文件、头文件的路径下了)

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

从执行的结果来看,有两个执行流死循环了,因为一个执行流是不可能执行两个死循环的,并且pid相同,所以这是属于同一进程的两个线程(ps axj 是查看进程的,说明一个进程里有两个执行流)

创建线程函数的第四个参数需要格外注意

 

如果在创建多个线程的for循环内创建临时变量,并将该临时变量的地址强转为void*传递给线程函数,那么在下一次for循环时该临时变量就已经被销毁了,如果线程函数依旧使用这个已经被销毁的临时变量的地址,这会出现段错误 

如果想让线程独立,那么就尽量不要传递地址,除非是malloc出来的对空间 

3. ps -aL 

 如何查看执行流(轻量级进程)?

ps -aL

  • PID:进程id
  • LWP(light weight proess):轻量级进程的id
  • TTY:终端

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

那么我们之前学的CPU调度看的是进程PID,这个理解是错误的吗?

并不是,因为我们之前的进程都是单进程,没有涉及多线程,即进程内只有一个线程,就是主线程。并且单进程中的唯一个执行流(主线程)的 PID 与 LWP 相等,所以我们之前学习的进程就是一个只有主线程的进程,即我们之前理解的并没有错误,之前学的单进程是当前线程知识的子集部分(只有一个主线程的情况)

CPU在调度时看的是LWP -- 轻量级进程的id,它可以根据PID与LWP是否相等,来判断哪一个是主线程,哪些是新线程。对于主线程来说,它就要对整个进程资源做监视(判断时间片是否用完等任务)

任意一个线程被杀死,整个进程就会被杀死

因为线程是进程的一个执行分支,线程收到信号,就是进程收到信号了,所以线程的健壮性很差,因为一个线程被干掉了,其他的线程都得退出。而进程就不会,因为进程之间具有独立性。

全局函数、全局变量初始化、未初始化的变量,进程内的执行流都共享

函数共享(可重入函数): 

进程中一些东西代码和数据是独立的,而其余部分对线程来说是共享的,因为共用一个地址空间 。并且线程之间通信太简单了!因为进程间通信本质就是让不同的进程看到同一份资源,而线程共享同一个地址空间,具有天然优势

全局变量共享:

 验证线程异常导致进程退出

先写一个脚本,循环打印mythread进程内的执行流信息

while :; do ps -aL | head -1 && ps -aL | grep mythread | grep -v grep;echo "------------------------------------------" ;sleep 1; done

 在新线程执行函数内编写一个除零异常

观察结果: 两个执行流在新线程发生异常后直接退出,即进程被终止,资源被释放,线程也被释放

向新线程传参

由于线程执行的函数类型被规定了,返回值为void*、参数是void*,所以在pthread_create时的第四个参数,要强转为void*类型,由于我们传递的 "Thread 1" 是常量字符串,所以要手动强转类型 

获取线程ID

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

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

pthread_self函数的函数原型如下:

pthread_t pthread_self(void);


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

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

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

 4. tid

pthread_create的第一个参数 tid 究竟是什么?

首先,Linux不提供真正的线程(没有专门实现线程结构,而是用进程模拟的轻量化进程),即OS只有LWP这个概念(轻量化进程),也就意味着操作系统只需要对内核执行流LWP进行管理。

但是用户还需要线程的其他属性,如线程id、线程的栈、线程产生的临时的私有的数据,这些数据在LWP中是没有体现的,而原生线程库内有许多的线程,原生线程库需要管理线程,所以线程库内会创建线程控制块TCB来管理控制线程。所有被创建出来的线程,都要被线程库一个一个的管理。

原生线程库是动态库,它会被加载到内存,并被各进程(使用原生线程库的进程)的页表映射到地址空间的共享区中,每创建一个线程,在原生线程库内就会多一个TCB控制块来管理,即供用户使用的线程接口等其他数据应该由线程库自己来管理,因此管理线程时的“先描述,再组织”就应该在线程库里进行,并且每个控制块是连续存放的,所以线程库可以在应用层对外提供控制块的起始地址,来充当tid!

所以,tid就是地址空间内的一个虚拟地址,对应线程库当中,线程结构体的起始地址,而LWP是OS底层管理执行流的编号,Linux线程是用户级线程,用户级执行流与内核lwp是一比一的对应关系

所以Linux线程 == 用户级线程 + 内核LWP

执行流的本质其实就是一条调用链,一个较深的执行流本质就是在栈区开辟栈帧再开辟栈帧...,栈结构是为了支持调用链产生的临时变量的存储,主线程使用地址空间的栈区,新线程在共享区自己开辟栈区,每个线程(执行流)都有各自独立的栈区,让自己的调用链不受其他执行流的干扰 

通过ldd命令可以看到,我们采用的线程库实际上是一个动态库

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

我们说每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。

上面我们所用的各种线程函数,本质都是在库内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的。

pthread_t到底是什么类型取决于实现,但是对于Linux目前实现的NPTL线程库来说,线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程。

5. 线程等待

线程谁先运行我们不知道,这由调度器决定,但是主线程一定是最后退出,因为是主线程创建的新线程,所以线程要管理新线程,所以主线程要最后才能退出。与子进程类似,被主线程创建的新线程退出后也要被等待,不然也会导致出现类似相似的僵尸子进程的危害--内存泄露

man pthread_join

参数说明:

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

返回值说明:

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

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

总结如下:

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

线程库内的函数都没有出错自动设置errno错误码的行为,而是返回0或错误码,这就方便每个线程都可以独立的返回错误码,而不是共享同一个errno

当一个线程函数执行完后,线程默认就直接退出

可以看到主线程并没有直接退出,而是等待新线程退出后再退出 ,即main thread在等待的时候是默认阻塞等待

想知道线程的退出信息,就用到第二个参数

void** retval:线程函数的 void* 类型返回值在内核存储,用户不能直接获取,用户层要使用 pthread_join(tid, void **retval)函数获取,在用户层要定义一个 void* x (使 retval 指向 x)来获取线程返回的 void* 内容 ,即 *retval = z,此时 *reatval 就指向了线程函数的 void* 类型返回值

注意:任何形参都会产生临时变量,指针也会产生临时变量

void* 是8字节,所以如果要输出int类变量,要将返回值强转为long long int,不然会发生截断编译出错

为什么 join 的时候不考虑异常?

如果我们等待的是一个进程,那么当这个进程退出时,我们可以通过wait函数或是waitpid函数的输出型参数status,获取到退出进程的退出码、退出信号以及core dump标志。

那为什么等待线程时我们只能拿到退出线程的退出码?难道线程不会出现异常吗?

线程在运行过程中当然也会出现异常,线程和进程一样,线程退出的情况也有三种:

  • 代码运行完毕,结果正确。
  • 代码运行完毕,结果不正确。
  • 代码异常终止。

因此我们也需要考虑线程异常终止的情况,但是pthread_join函数无法获取到线程异常退出时的信息。因为线程是进程内的一个执行分支,如果进程中的某个线程崩溃了,那么整个进程也会因此而崩溃,此时我们根本没办法执行pthread_join函数,因为整个进程已经退出了。

所以pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正确。

6. 线程终止

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

  1. 线程函数 return
  2. 调用 pthread_exit(retval)
  3. 主线程内调用 pthread_cancel (tid),被取消时线程函数的返回值默认是-1(PTHREAD_CANCELED)

return退出 

        exit是用来终止进程的,不能用来终止线程,任何线程一旦使用exit就会进程退出,不会单独线程退出。

        在线程中使用return代表当前线程退出,但是在main函数中使用return代表整个进程退出,也就是说只要主线程退出了那么整个进程就退出了(主线程调用pthread_exit除外,此时进程不会退出,主线程会变成僵尸状态),此时该进程曾经申请的资源就会被释放,而其他线程会因为没有了资源,自然而然的也退出了。

例如,在下面代码中,主线程创建五个新线程后立刻进行return,那么整个进程也就退出了

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	while (1){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
	}
	return (void*)0;
}
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 %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());

	return 0;
}

主线程没有阻塞等待回收新线程 

调用 pthread_exit(retval)退出 

参数说明:

  • retval:线程退出时的退出码信息。

说明一下:

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

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

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	int count = 0;
	while (count < 5){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
	}
	pthread_exit((void*)6666);
}
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 %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
	for (int i = 0; i < 5; i++){
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (int)ret);
	}
	return 0;
}

 

注意: 

  • exit函数的作用是终止进程,任何一个线程调用exit函数也代表的是整个进程终止。 
  • 主线程pthread_exit(),调用后主线程会僵尸,新线程不会退出

 线程返回值不仅仅只可以用来传递一般参数,也可以传递对象!

 

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//请求类
class Request
{
public:
    Request(int begin, int end, const string& threadname)
        :_begin(begin), _end(end), _threadname(threadname)
    {}
public:
    int _begin;         //起始值
    int _end;           //终止值
    string _threadname; //线程名称
};

//返回类
class Response
{
public:
    Response(int result, int exitcode)
        :_result(result), _exitcode(exitcode)
    {}
public:
    int _result;        //计算结果
    int _exitcode;      //计算结果是否有效
};

void* sumCount(void* args)
{
    Request* rq = static_cast<Request*>(args);
    Response* rsp = new Response(0, 0);         //new一个Response类型对象,默认给0,0
    for (int i = rq->_begin; i <= rq->_end; i++)
    {
        cout << rq->_threadname << " is running, caling... " << i << endl;
        rsp->_result += i;                      //求和计算
        usleep(50000);
    }
    delete rq;      //delete释放空间
    return rsp;     //线程返回值
}

int main()
{
    pthread_t tid;
    Request* rq = new Request(1, 100, "Thread 1");      //参数是Request对象,证明线程创建时的传参不仅仅只能传普通类型
    pthread_create(&tid, nullptr, sumCount, rq);        //创建新线程,让新线程执行1-100的加法运算

    void* ret;                                          //ret接收线程执行函数的返回值,使用void*类型
    pthread_join(tid, &ret);                            //线程等待,并获取返回值到ret指针中
    Response* rsp = static_cast<Response*>(ret);        //将ret强转为Response类型
    cout << "rsp->_restlt: " << rsp->_result << ", exitcode: " << rsp->_exitcode << endl;
    delete rsp;         //delete释放空间
    return 0;
}

根据此例,我们可以创建多个线程,并发的执行多个范围的求和运算,最后在主线程处再求和,即可快速得出结果

pthread_cancel函数

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

int pthread_cancel(pthread_t thread);

参数说明:

  • thread:被取消线程的ID。

返回值说明:

  • 线程取消成功返回0,失败返回错误码。

线程是可以取消自己的,取消成功的线程的退出码一般是-1。例如在下面的代码中,我们让线程执行一次打印操作后将自己取消。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	int count = 0;
	while (count < 5){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
		pthread_cancel(pthread_self());
	}
	pthread_exit((void*)6666);
}
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 %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
	for (int i = 0; i < 5; i++){
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (int)ret);
	}
	return 0;
}

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

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

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

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	int count = 0;
	while (count < 5){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
	}
	pthread_exit((void*)6666);
}
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 %lu\n", buffer, tid[i]);
	}
	pthread_cancel(tid[0]);
	pthread_cancel(tid[1]);
	pthread_cancel(tid[2]);
	pthread_cancel(tid[3]);
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
	for (int i = 0; i < 5; i++){
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (int)ret);
	}
	return 0;
}

此时可以发现,0、1、2、3号线程退出时的退出码不是我们设置的6666,而只有未被取消的4号线程的退出码是6666,因为只有4号进程未被取消。

此外,新线程也是可以取消主线程的,例如下面我们让每一个线程都尝试对主线程进行取消。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

pthread_t main_thread;

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	int count = 0;
	while (count < 5){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
		pthread_cancel(main_thread);
	}
	pthread_exit((void*)6666);
}
int main()
{
	main_thread = pthread_self();
	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 %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
	for (int i = 0; i < 5; i++){
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (int)ret);
	}
	return 0;
}

可以看到一段时间后,六个线程中PID和LWP相同的线程,也就是主线程右侧显示<defunct>,也就意味着主线程已经被取消了,我们也就看不到后续主线程等待新线程时打印的退出码了。

注意:

  1. 当采用这种取消方式时,主线程和各个新线程之间的地位是对等的,取消一个线程,其他线程也是能够跑完的,只不过主线程不再执行后续代码了。
  2. 我们一般都是用主线程去控制新线程,这才符合我们对线程控制的基本逻辑,虽然实验表明新线程可以取消主线程,但是并不推荐该做法。

创建多线程,验证每个线程都具有独立的栈结构保存产生的临时变量(共享地址空间的堆区)

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;

//线程数量
const int NUM = 3;

struct threadData
{
    string threadname;
};

//将tid转为16进制
string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%p", tid);
    return buffer;
}

//初始化结构体
void InitThreadData(threadData* td, int number)
{
    td->threadname = "Thread-" + to_string(number);
}

//线程执行函数(此函数为可重入函数)
void* threadRoutine(void* args)
{
    //验证test_i是否独立,各线程应有自己的栈结构存储临时变量
    int test_i = 0;
    threadData* td = static_cast<threadData*>(args);        //static_cast<>()强转

    int i = 0;
    while (i < 10)
    {
        printf("pid: %d, tid: 0x%s, threadname: %s, test_i: %d, &test_i: %p\n", getpid(), toHex(pthread_self()).c_str(), td->threadname.c_str(), test_i, &test_i);

        sleep(1);
        i++, test_i++;      //验证函数内的临时变量test_i是否独立
        // i++, g_val++;      //验证全局数据是否共享
    }

    delete td;              //释放堆空间
    return nullptr;
}

int main()
{
    //保存每个线程tid
    vector<pthread_t> tids;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        //这里不能直接创建对象,因为在for函数内部,对象创建后,下一次for循环时对象就已经被销毁,那么线程在使用时会出现野指针问题
        //所以必须要在堆区创建对象,再传递到线程的执行函数中
        threadData* td = new threadData;                //验证堆区数据线程共享,但是每个线程拿到的堆区数据都独立的,因为每次循环都是new新的
        InitThreadData(td, i);

        pthread_create(&tid, nullptr, threadRoutine, td);
        tids.push_back(tid);
    }

    //等待新线程
    for (int i = 0; i < tids.size(); i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

发现 test_i 的地址不同,数据是独立的 

主线程能访问任意一个新线程的栈区数据吗?

可以,因为这些新线程的栈区在地址空间的共享区,主线程当然能访问,但是线程要保持自己数据的独立(并不是私有,私有表示不让别人查看),虽然主线程可以访问,但是平时禁止这么干。线程与线程之间几乎没有秘密,线程的栈上的数据都可以互相看到

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;

const int NUM = 3;

//验证主线程可以访问新线程的数据
int* p = nullptr;

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%p", tid);
    return buffer;
}

void InitThreadData(threadData* td, int number)
{
    td->threadname = "Thread-" + to_string(number);
}

void* threadRoutine(void* args)
{
    //验证test_i是否独立,各线程应有自己的栈结构存储临时变量
    int test_i = 0;

    threadData* td = static_cast<threadData*>(args);

    //验证主线程可以访问新线程的数据
    if (td->threadname == "Thread-2") p = &test_i;      

    int i = 0;
    while (i < 10)
    {
        printf("pid: %d, tid: 0x%s, threadname: %s, test_i: %d, &test_i: %p\n", getpid(), toHex(pthread_self()).c_str(), td->threadname.c_str(), test_i, &test_i);

        sleep(1);
        i++, test_i++;      //验证函数内的临时变量test_i是否独立
        // i++, g_val++;      //验证全局数据是否共享
    }

    delete td;              //释放堆空间
    return nullptr;
}

int main()
{
    vector<pthread_t> tids;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        //这里不能直接创建对象,因为在for函数内部,对象创建后,下一次for循环时对象就已经被销毁,那么线程在使用时会出现野指针问题
        //所以必须要在堆区创建对象,再传递到线程的执行函数中
        threadData* td = new threadData;                //验证堆区数据线程共享,但是每个线程拿到的堆区数据都独立的,因为每次循环都是new新的
        InitThreadData(td, i);

        pthread_create(&tid, nullptr, threadRoutine, td);
        tids.push_back(tid);
    }

    sleep(1);
    cout << "main thread get a thread local value, val: " << *p << ", &val" << p << endl;

    for (int i = 0; i < tids.size(); i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

main函数内主线程获取了新线程栈内的数据

再简单看一下线程共享全局数据

#include <iostream>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;

// 创建线程数量
const int NUM = 3;

// 验证主线程可以访问新线程的数据
//  int* p = nullptr;
int g_val = 100;

struct threadData
{
    string threadname;
};

// 将tid转为16进制数
string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%p", tid);
    return buffer;
}

// 初始化结构体成员
void InitThreadData(threadData *td, int number)
{
    td->threadname = "Thread-" + to_string(number);
}

// 线程执行函数(此函数为可重入函数)
void *threadRoutine(void *args)
{
    // 验证test_i是否独立,各线程应有自己的栈结构存储临时变量
    int test_i = 0;
    threadData *td = static_cast<threadData *>(args); // static_cast<>()强转

    // // 验证主线程可以访问新线程的数据
    // if (td->threadname == "Thread-2") p = &test_i;

    int i = 0;
    while (i < 10)
    {
        //验证test_i
        // cout << "pid: " << getpid() << ", tid: " << toHex(pthread_self())
        //     << ", threadname: " << td->threadname << ", test_i: " << test_i
        //         << ", &test_i: " << &test_i << endl;
        // printf("pid: %d, tid: 0x%s, threadname: %s, test_i: %d, &test_i: %p\n", getpid(), toHex(pthread_self()).c_str(), td->threadname.c_str(), test_i, &test_i);

        //验证g_val
        printf("pid: %d, tid: 0x%s, threadname: %s, g_val: %d, &g_val: %p\n", getpid(), toHex(pthread_self()).c_str(), td->threadname.c_str(), g_val, &g_val);
 
        sleep(1);
        //i++, test_i++;          // 验证函数内的临时变量test_i是否独立
        i++, g_val++;              //验证全局数据是否共享
    }

    delete td; // 释放堆空间
    return nullptr;
}

int main()
{
    // 保存线程tid
    vector<pthread_t> tids;
    for (int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        // 这里不能直接创建对象,因为在for函数内部,对象创建后,下一次for循环时对象就已经被销毁,那么线程在使用时会出现野指针问题
        // 所以必须要在堆区创建对象,再传递到线程的执行函数中
        threadData *td = new threadData; // 验证堆区数据线程共享,但是每个线程拿到的堆区数据都独立的,因为每次循环都是new新的
        InitThreadData(td, i);

        pthread_create(&tid, nullptr, threadRoutine, td);
        tids.push_back(tid);
    }

    // sleep(1);
    // cout << "main thread get a thread local value, val: " << *p << ", &val" << p << endl;

    // 等待新线程
    for (int i = 0; i < tids.size(); i++)
    {
        pthread_join(tids[i], nullptr);
    }

    return 0;
}

格外注意多线程创建时的第四个参数

 

这里不能直接创建对象,因为在for函数内部,对象创建后,下一次for循环时对象就已经被销毁,那么线程在使用时会出现野指针问题,所以必须要在堆区创建对象,再传递到线程的执行函数中

threadData *td = new threadData;

每个线程拿到的堆区数据都独立的,因为每次循环都是new新的

 

全局变量被所有线程共享、访问,那么线程如何定义一个私有的全局变量呢?

线程的局部存储:

全局变量前+__thread就可以实现,它是编译器提供的编译选项,编译时会在每个线程的局部存储空间内开辟空间存放一份,并且 __thread(局部存储)只能修饰内置类型,不能修饰自定义类型

 

 此时g_val在每个线程的局部存储区都开辟了空间,成为了每个线程的全局变量

C++也支持多线程,引入thread头文件即可,但是在g++编译时依然要加 -lpthread 选项,因为C++语言使用的多线程本质就是封装的原生线程库

原生线程库要被加载到内存,新线程的栈在共享区中 

线程库要维护线程的概念,不用维护线程的执行流,线程库注定要维护多个线程属性的集合,那么先描述再组织,在线程库(用户级)线程控制块,也称为用户级线程

每一个线程在动态库共享区的起始地址称为tid(虚拟地址、在用户空间),可以根据tid快速拿到

每个新线程(轻量级进程)都要有自己的栈结构(地址空间的共享区,具体来说时在pthread库中,tid指向用户的tcb),用来存储任何执行流在执行时产生的临时变量。主线程直接使用地址空间的栈空间

7. 线程分离

  • 默认情况下,新创建的线程是 joinable 的(可等待),线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,那么 join 是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

线程的释放本质就是在用户层将原生线程库内部的tcb结构体、栈等数据结构释放,再将OS内的LWP也释放。

分离动作既可以是主线程做,也可以是新线程来做

man pthread_detach

参数说明:

  • thread:被分离线程的ID。

返回值说明:

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

主线程处分离,验证:

一旦线程被分离,那么再次 join 就会出错,参数错误

新线程自身分离,验证:

 

为什么分离之后,我们的输出减少了?

因为主线程的join不再阻塞等待新线程,每一次的join都会出错退出,然后就会join下一个新线程,所以主线程很快就执行到return 0,从而导致进程退出,导致进程资源被释放,线程赖以生存的资源也随之消失,线程退出。

所以如果要进行线程分离,那么一定要保证主线程是最后退出的