【Linux多线程】

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

Linux多线程

Linux线程概念

什么是线程

  • 在一个程序里的一个执行路线就叫做线程thread。更准确的定义是线程是“一个进程内部的控制序列”。
  • 一切进程至少都有一个执行线程。
  • 线程在进程内部运行本质是在进程地址空间内运行。
  • 在Linux系统中在CPU眼中看到的PCB都要比传统的进程更轻量化。
  • 透过进程虚拟地址空间可以看到进程的大部分资源将进程资源合理分配给每个执行流就形成了线程执行流。

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

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

但如果我们在创建“进程”时只创建task_struct并要求创建出来的task_struct和父task_struct共享进程地址空间和页表那么创建的结果就是下面这样的
在这里插入图片描述
此时我们创建的实际上就是四个线程

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

注意 单纯从技术角度这个是一定能实现的因为它比创建一个原始进程所做的工作更轻量化了。

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

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

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

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

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

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

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

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

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

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

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

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

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

这很好理解既然在Linux中都没有真正意义上的线程了那么自然也没有真正意义上的线程相关的系统调用了。但是Linux可以提供创建轻量级进程的接口也就是创建进程共享空间其中最典型的代表就是vfork函数。

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

pid_t vfork(void);

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

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

只不过vfork函数创建出来的子进程与其父进程共享地址空间。

原生线程库pthread

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

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

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

线程的优点

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

线程的缺点

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

线程异常

  • 单个线程如果出现除零、野指针等问题导致线程崩溃进程也会随着崩溃。
  • 线程是进程的执行分支线程出异常就类似进程出异常进而触发信号机制终止进程进程终止该进程内的所有线程也就随即退出。

线程用途

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

Linux进程VS线程

进程和线程

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

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

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

进程的多个线程共享

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

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

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

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

Linux线程控制

POSIX线程库

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

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

错误检查

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

线程创建

创建线程的函数叫做pthread_create

pthread_create函数的函数原型如下

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

参数说明

  • thread获取创建成功的线程ID该参数是一个输出型参数。
  • attr用于设置创建线程的属性传入NULL表示使用默认属性。
  • start_routine该参数是一个函数地址表示线程例程即线程启动后要执行的函数。
  • arg传给线程例程的参数。

返回值说明

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

让主线程创建一个新线程

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

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

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

  • 默认情况下不带-L看到的就是一个个的进程。
  • 带-L就可以查看到每个进程内的多个轻量级进程。
    在这里插入图片描述
    其中LWPLight Weight Process就是轻量级进程的ID可以看到显示的两个轻量级进程的PID是相同的因为它们属于同一个进程。

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

获取线程ID

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

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

pthread_self函数的函数原型如下

pthread_t pthread_self(void);

返回值 当前线程的ID。

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

线程等待

首先需要明确的是一个线程被创建出来这个线程就如同进程一般也是需要被等待的。如果主线程不对新线程进行等待那么这个新线程的资源也是不会被回收的。所以线程需要被等待如果不等待会产生类似于“僵尸进程”的问题也就是内存泄漏。

等待线程的函数叫做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参数。
    用grep命令进行查找可以发现PTHREAD_CANCELED实际上就是头文件<pthread.h>里面的一个宏定义它的值本质就是-1。
[cl@VM-0-15-centos thread]$ grep -ER "PTHREAD_CANCELED" /usr/include/

线程终止

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

  • 从线程函数return。
  • 线程可以自己调用pthread_exit函数终止自己。
  • 一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

return退出

在线程中使用return代表当前线程退出但是在main函数中使用return代表整个进程退出也就是说只要主线程退出了那么整个进程就退出了此时该进程曾经申请的资源就会被释放而其他线程会因为没有了资源自然而然的也退出了。

pthread_exit函数

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

void pthread_exit(void *retval);

参数说明

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

说明一下

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

pthread_cancel函数

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

int pthread_cancel(pthread_t thread);

参数说明

  • thread被取消线程的ID。
    返回值说明

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

分离线程

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

分离线程的函数叫做pthread_detach

pthread_detach函数的函数原型如下

int pthread_detach(pthread_t thread);

参数说明

  • thread被分离线程的ID。
    返回值说明

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

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

  • pthread_create函数会产生一个线程ID存放在第一个参数指向的地址中该线程ID和内核中的LWP不是一回事。
  • 内核中的LWP属于进程调度的范畴因为线程是轻量级进程是操作系统调度器的最小单位所以需要一个数值来唯一表示该线程。
  • pthread_create函数第一个参数指向一个虚拟内存单元该内存单元的地址即为新创建线程的线程ID这个ID属于NPTL线程库的范畴线程库的后续操作就是根据该线程ID来操作线程的。
  • 线程库NPTL提供的pthread_self函数获取的线程ID和pthread_create函数第一个参数获取的线程ID是一样的。

pthread_t到底是什么类型呢

首先Linux不提供真正的线程只提供LWP也就意味着操作系统只需要对内核执行流LWP进行管理而供用户使用的线程接口等其他数据应该由线程库自己来管理因此管理线程时的“先描述再组织”就应该在线程库里进行。

通过ldd命令可以看到我们采用的线程库实际上是一个动态库。
在这里插入图片描述
进程运行时动态库被加载到内存然后通过页表映射到进程地址空间中的共享区此时该进程内的所有线程都是能看到这个动态库的。
在这里插入图片描述
我们说每个线程都有自己私有的栈其中主线程采用的栈是进程地址空间中原生的栈而其余线程采用的栈就是在共享区中开辟的。除此之外每个线程都有自己的struct pthread当中包含了对应线程的各种属性每个线程还有自己的线程局部存储当中包含了对应线程被切换时的上下文数据。

每一个新线程在共享区都有这样一块区域对其进行描述因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址然后就可以获取到该线程的各种信息。
在这里插入图片描述
上面我们所用的各种线程函数本质都是在库内部对线程属性进行的各种操作最后将要执行的代码交给对应的内核级LWP去执行就行了也就是说线程数据的管理本质是在共享区的。

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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux