上海网站 工作室室,网站收索流量,做网站前的准备,离线网页制作工具一、线程概念 线程是进程的一个执行分支#xff0c;是在进程内部运行的一个执行流。下面将从是什么、为什么、怎么办三个角度来解释线程。 1、什么是线程 上面是一张用户级页表#xff0c;我们都知道可执行程序在磁盘中无非就是代码或数据#xff0c;更准确点表述#xff0…一、线程概念 线程是进程的一个执行分支是在进程内部运行的一个执行流。下面将从是什么、为什么、怎么办三个角度来解释线程。 1、什么是线程 上面是一张用户级页表我们都知道可执行程序在磁盘中无非就是代码或数据更准确点表述代码也是一种数据程序一运行实际就会将其加载到物理内存因为每个进程都有地址空间和页表所以可以通过用户页表映射物理内存的方式来找到磁盘或内存中的数据。
如果想创建一个进程那么这个进程也应该有自己独立的 task_struct、mm_struct、用户页表。如果创建 “进程”而不独立创建 mm_struct、用户页表也不进行 I/O 将程序的代码和数据加载到内存只创建 task_struct然后让新的 PCB 指向和老的 PCB 指向同样的 mm_struct再把代码分成多份通过当前进程的资源的合理的分配让 CPU 执行不同 PCB 时访问的是不同的代码都能使用进程的一部分资源在系统层面上这里一定可以做到把不同的代码分配给不同的执行流只要划分用户级页表就可以让不同的 PCB 看到页表的一部分此时就只能看到进程资源的一部分。站在资源的角度来说地址空间其实是进程 pcb 的资源窗口之前是只有一个窗户和一个人现在是有多个窗户和多个人。每个 PCB 被 CPU 调度时执行的粒度一定是要比原始进程执行的粒度要更小那么我们就称比进程执行粒度更小的为 Linux 线程这是 Linux 线程的原理。 什么叫做线程如何理解线程是在进程的内部运行什么叫做线程是进程的一个执行分支
什么是进程呢 站在数据结构角度仅仅 PCB 已经代表不了进程了我们要明确进程就是 task_struct mm_struct 用户级页表 物理内存中映射的代码和数据。 站在 OS 系统角度谈进程是承担分配系统资源的基本实体。 在创建第 2 个、第 3 个线程时对应的线程它们所需的资源早就已经申请好了而这些资源是曾经进程就已经申请好的资源这就是所谓承担。OS 在分配资源时不是以一个独立线程去分配而是先由进程分配资源然后每个线程再向进程要资源。 举个生活中的例子你可以向你爸妈要钱当你不要时这个钱也早就被你爸妈赚到了而你爸妈赚的是社会的钱社会就相当于整个 OS。而你爸妈构建的这一个家庭就是承担分配社会资源的基本实体基本实体就像一套房它不是一人一套而是一个家庭一套。 所以解决了这个问题那么前三个问题就迎刃而解了子女等是在家庭内部运行线程是在进程的内部运行。子女等是家庭的基本单位线程是 OS 调度的基本单位。子女等是一个家庭的执行分支线程是进程的一个执行分支。 也就是说一个进程被创建好后后续内存可能存在多个执行流即多线程。现在再站在数据结构角度上看还要明确进程就是 task_struct task_struct task_struct … … mm_struct 用户级页表 物理内存中映射的代码和数据而其中内部的一个执行流只能称为线程。
所以我们再以现在的角度看以前在进程控制、基础 I/O、进程通信中所讲的进程其实都没有问题只不过以前讲的进程其内部只有一个执行流罢了。 1Linux 线程 VS 其它平台的线程
前面所谈的本质就是 Linux 线程的基本原理。站在 CPU 的角度对比历史的进程当然没有区别CPU 看到的还是一个个的 PCB只不过 CPU 执行时“可能” 执行的 “进程流” 已经比历史的更加轻量化了。这很好理解同一个效率的不同进程前者只有 1 个执行流而后者有 4 个执行流且进行了合理的资源分配。所以当执行后者时可能就会比前者更加轻量化注意 5 个 PCB 在 CPU 的等待队列中排队CPU 在调度时都是按照 1 个 PCB 为单位正常调用它不关心你前者和后者有几个 PCB。再者假设后者两个执行流要进行切换上下文数据也少不了切换但 mm_struct、用户页表、代码和数据完全不用管这相对历史进程切换就显得更加轻量化了。
Linux 下其实并没有真正意义上的线程概念而是用进程 pcb 模拟的线程。Linux 并不能直接给我们提供线程相关的接口只能提供轻量级进程的接口。换而言之我们 Linux 下的进程往往比其它平台上的进程更加轻量化是因为它有可能是只有一个线程的进程也有可能是有多个线程的进程所以我们把 Linux 下的进程称为轻量级进程。所以站在 Linux 系统的角度我们不区分它是线程还是进程而统一轻量级进程。
Windows 具有真正意义上的线程概念。系统中一定存在着大量的进程而进程 : 线程 1 : n所以系统也一定存在大量的线程而且不比进程少OS 也一定要管理线程那应该如何管理呢—— 先描述再组织。所以支持真线程的系统一定要先描述线程 TCBThread Control Block其内部一定是 PCB TCB 共生系统中已经存在了大量的 PCB还要存在更大量的 TCB然后 TCB 还要和 PCB 产生某些关系以证明该线程是该进程内的一个执行流这样一来 OS 既要进程管理又要线程管理就一定会使得该 OS 设计的很复杂其中在描述 TCB 的时候也一定需要和 PCB 类似的各种属性。
但实际上可以发现线程和进程一样也是一种执行流所以一定的是 PCB 和 TCB 在描述时会存在着大量重复的属性。所以我们可以看到 Windows 确实存在多线程只不过代价很大而 Linux 看到后无论你是什么线程同样也是执行流所以就把进程和线程统一了所以 Linux Kernel 中就没有线程 TCB 的概念。所以Windows 在 OS 层面下一定提供了相关线程控制的接口而 Linux 下虽然设计的更简单了但它不可能在 OS 层面提供线程控制的相关系统调用接口最多提供了轻量级进程相关的系统调用接口如 vfork、clone。实际在应用层 Linux 下有一套系统级别的原生线程库 pthread原生线程库就是在应用层实现的库。其实 C、Python 等支持多线程的这些语言是有自己原生写好的线程的且底层一定是用到下面要讲的 pthread。 注意在编译时需要 -l 在默认路径下指定 pthread 动态库否则在编译时会报错在指定 pthread 库后ldd 就可以看到成功了。 可以看到主线程每 1 秒执行 while 循环新线程每 1 秒执行回调函数严格来说新线程是指主线程调用 pthread_create 时pthread_create 再去调用 start_routine 的情况。之前是 ps ajx 来查看一个进程的相关信息而现在可以用 ps -aL 来查看轻量级进程可以发现后者的 PID 是相同的这就说明两个 mythread 本质是属于一个进程而每一个线程也需要唯一标识。所以LWP 用来标识线程的唯一性PID 标识进程的唯一性。这里还可以看到的是第一组 PID 和 LWP 的值是相同的如果进程内部是单执行流时此时 ps ajx 和 ps -aL 查看时 PID 和 LWP 的值是一样的所以在多线程之前 PID 和 LWP 的意义是一样的。所以调度进程 CPU 看的是 LWP那么也就说明了如果 PID 和 LWP 两个值是相同的那么对应的 LWP 对应的 ID 就是主线程。当我们杀死新线程时也会影响主线程说明它们是共生的。 2小结 在一个程序里的一个执行路线就叫做线程thread更准确的定义是线程是 “一个进程内部的控制序列”。一切进程至少都有一个执行线程。线程在进程内部运行本质是在进程地址空间内运行。在 Linux 系统中在 CPU 眼中看到的 PCB 都要比传统的进程更加轻量化。透过进程虚拟地址空间可以看到进程的大部分资源将进程资源合理分配给每个执行流就形成了线程执行流。 2、线程的优点 创建一个新线程的代价要比创建一个新进程小得多。与进程之间的切换相比线程之间的切换需要操作系统做的工作要少很多。线程占用的资源要比进程少很多。能充分利用多处理器的可并行数量。在等待慢速 I/O 操作结束的同时程序可执行其他的计算任务。计算密集型应用为了能在多处理器系统上运行将计算分解到多个线程中实现。I/O密集型应用为了提高性能将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。 3、线程的缺点
因为所有 PCB 共享地址空间在理论上每个线程都能访问进程的所有资源所以这里还有一个好处就是线程间通信的成本很低同样缺点也很明显其内部一定存在着临界资源所以可能需要使用各种同步与互斥机制。当然线程并不是越多越好而是合适就好如果线程增加的太多可能 大部分时间 CPU 并不是在计算而是在进行线程切换。就好比一家公司每个人都各自清楚的做着事情但当公司人数过多可能反而会导致公司效率变低。 1性能损失 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多那么可能会有较大的性能损失这里的性能损失指的是增加了额外的同步和调度开销而可用的资源不变。 2健壮性降低 编写多线程需要更全面更深入的考虑在一个多线程程序里因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的换句话说线程之间是缺乏保护的。 3缺乏访问控制 进程是访问控制的基本粒度在一个线程中调用某些 OS 函数会对整个进程造成影响。 4编程难度提高 编写与调试一个多线程程序比单线程程序困难得多。 4、线程异常 单个线程如果出现除零野指针问题导致线程崩溃进程也会随着崩溃。线程是进程的执行分支线程出异常就类似进程出异常进而触发信号机制终止进程进程终止该进程内的所有线程也就随即退出。 5、线程用途 合理的使用多线程能提高 CPU 密集型程序的执行效率。合理的使用多线程能提高 IO 密集型程序的用户体验。比如我们一边写代码一边下载开发工具就是类似多线程运行的一种表现 二、进程 VS 线程
1、进程和线程
进程是资源分配的基本单位。线程是调度的基本单位。线程共享进程数据共享的进程数据包括代码区、字符常量区、全局初始化和未初始化数据、堆区、共享区、命令行参数和环境变量、内核区等等。但也独立拥有自己的一部分数据这一部分数据是不共享的
线程 ID即 LWP对应的寄存器数据CPU 调度是按 PCB 调度的每个线程都有自己的上下文数据所以必须保证每个线程的上下文数据是各自私有的这也体现了线程是可以切换的栈每个线程都有自己的栈结构这也体现了线程是独立运行的errno 信号屏蔽字多线程中 block 表是私有的调度优先级 进程的多个线程共享同一地址空间 因此 Text Segment 、 Data Segment 都是共享的 如果定义一个函数 在各线程中都可以调用 如果定义一个全局变量 在各线程中都可以访问到 除此之外 各线程还共享以下进程资源和环境 文件描述符表注意在多进程中不共享文件描述符表在管道我们说过两个进程可以指向同一个文件其中并是说它们共享文件描述符表而是它们表中的内容是一样的而多线程是可以共享文件描述符表的每种信号的处理方式SIG_ IGN、SIG_ DFL 或者自定义的信号处理函数handler 表是线程共享的当前工作目录 用户 id 和组 id
为什么线程调度的成本更低呢 如果在一个进程内调度其若干个线程首先地址空间和页表也不需要切换。CPU 内部是有硬件级别 L1~L3 的缓存 Cache 根据局部性原理对内存的代码和数据来预读 CPU 内部。 如果进程切换 cache 就立即失效新进程过来就只能重新缓存。 【进程和线程的关系图 】 如何看待之前学习的单进程 具有一个线程执行流的进程。 三、线程控制 1、POSIX线程库 与线程有关的函数构成了一个完整的系列绝大多数函数的名字都是以 pthread_ 打头的。要使用这些函数库要通过引入头文 pthread.h。链接这些线程函数库时要使用编译器命令的 -lpthread 选项。编译链接时需要 -l 指定 pthread 库。系统中是默认已经安装了 pthread 库。 POSIX 和 System -V 都是用于系统级接口的 IPC进程间通信标准它们可以用于多进程和多线程之间的通信。POSIX 是可移植操作系统接口由 IEEE 制定的一系列标准旨在提高 OS 之间的互操作性。而 System 是 ATT 公司开发的它是 Unix 的一种版本。相比 System -VPOSIX 是一个比较新的标准语法也相对简单而 System -V 年代久远不过也因此有许多系统支持使用更加广泛。 由于没有固定标准所以不同 OS 之间存在一些差异在不同通信方式中两者都有利弊。 在信号量方面POSIX 在无竞争条件下不会陷入内核而 System -V 则是无论何时都要陷入内核因此后者性能略差。在消息队列方面POSIX 实现尚未完善System -V 仍是主流。 在多线程中基本使用的是 POSIX而在多进程中则是 System -V。 pthread 是 POSIX 线程库的一部分它提供了一组 API用于在多线程环境中创建和管理线程是一种轻量级进程。pthread 库囊括的东西很多最经典的是现在所谈的线程库和后面网络所谈的套接字。 2、创建线程 1接口介绍 thread输出型参数返回线程 ID。attr设置线程的属性attr 为 NULL 表示使用默认属性。start_routine想让线程执行的任务它是一个返回值 void*参数 void* 的一个函数指针。arg回调函数的参数若线程创建成功在执行 start_routine 时会把 arg 传入 start_routine。 2错误检查 传统的一些函数是成功返回 0失败返回 -1并且对全局变量 errno 赋值以指示错误。 pthreads 函数出错时不会设置全局变量 errno而大部分其他 POSIX 函数会这样做而是将错误代码通过返回值返回。pthreads 同样也提供了线程内的 errno 变量以支持其它使用 errno 的代码。对于pthreads 函数的错误建议通过返回值业判定因为读取返回值要比读取线程内的 errno 变量的开销更小 3代码 【除 0 错误】
这里让新线程执行除 0 操作我们发现它会影响整个进程。线程是进程的一个执行分支除 0 错误操作会导致线程退出的同时也意味着进程触发了该错误进而导致进程退出。这也就是线程会使用代码健壮性降低的一个表现。 哪个线程先运行跟调度器有关。线程一旦异常就有可能导致整个进程整体退出。 线程在创建并执行时线程也是需要进行等待的如果主进程不等待就会引起类似于进程的僵尸问题导致内存泄漏。 主线程可以直接获取新线程退出的结果 4、进程 ID 和线程 ID
在 Linux 中目前的线程实现是 Native POSIX Thread Libaray简称 NPTL。在这种实现下线程又被称为轻量级进程Light Weighted Process每一个用户态的线程在内核中都对应一个调度实体也拥有自己的进程描述符task_struct 结构体。 多线程的进程又被称为线程组线程组内的每一个线程在内核之中都存在一个进程描述符 task_struct与之对应。进程描述符结构体中的 pid 表面上看对应的是进程 ID 其实不然它对应的是线程 ID进程描述符中的 tgid 含义是 Thread Group ID 该值对应的是用户层面的进程 ID。 现在介绍的线程 ID 不同于 pthread_t 类型的线程 ID 和进程 ID 一样线程 ID 是 pid_t 类型的变量而且是用来唯一标识线程的一个整型变量。 ps 命令中的 -L 选项会显示如下信息 LWP线程 ID即 gettid() 系统调用的返回值。 NLWP线程组内线程的个数。 Linux 提供了 gettid 系统调用来返回其线程 ID可是 glibc 并没有将该系统调用封装起来在开放接口来共程序员使用。如果确实需要获得线程 ID可以采用如下方法
#include sys/syscall.h pid_t tid; tid syscall(SYS_gettid); 从上面可以看出a.out 进程的 ID 为 28543下面有一个线程的 ID 也 是28543这不是巧合。线程组内的第一个线程在用户态被称为主线程main thread在内核中被称为 group leader内核在创建第一个线程时会将线程组的 ID 的值设置成第一个线程的线程 IDgroup_leader 指针则指向自身既主线程的进程描述符。所以线程组内存在一个线程 ID 等于进程 ID而该线程即为线程组的主线程。 // 线程组ID等于线程IDgroup_leader指向自身
p-tgid p-pid;
p-group_leader p;
INIT_LIST_HEAD(p-thread_group); 至于线程组其他线程的 ID 则有内核负责分配其线程组 ID 总是和主线程的线程组 ID 一致无论是主线程直接创建线程还是创建出来的线程再次创建线程都是这样。 if (clone_flags CLONE_THREAD)p-tgid current-tgid;
if (clone_flags CLONE_THREAD)
{P-group_lead current-group_leader;list_add_tail_rcu(p-thread_group, p-group_leader-thread_group);
} 强调 线程和进程不一样进程有父进程的概念但在线程组里面所有的线程都是对等关系。 5、线程ID及进程地址空间布局和对原生线程库的理解 pthread_t 到底是什么类型呢 取决于实现对于 Linux 目前实现的 NPTL 实现而言 pthread_t 类型的线程 ID 本质就是一个进程地址空间上的一个地址。 Linux OS 没有真正意义上的线程而是用进程 PCB 模拟的这就叫作轻量级进程。其本身没有提供类似线程创建、终止、等待、分离等相关 System Call 接口但是会提供轻量级进程的接口如 clone。所以为了更好的适配系统基于轻量级进程的接口模拟封装了一个用户层的原生线程库 pthread。这样系统通过 PCB 来进行管理用户层也得知道线程 ID、状态、优先级等其它属性用来进行用户级线程管理。pthread_create 函数会产生一个线程 ID存放在第一个参数指向的地址中该线程 ID 和前面说的线程 ID LWP 不是一回事。前面讲的线程 ID 属于进程调度的范畴因为线程是轻量级进程是 OS 调度器的最小单位所以需要一个数值来唯一表示该线程。pthread_create 函数的第一个参数指向一个虚拟内存单元该内存单元的地址即为新创建线程的线程 ID属于 NPTL 线程库的范畴线程库的后续操作就是根据该线程 ID 来操作线程。原生线程库是一个库它在磁盘上就是一个 libpthread.so 文件运行时加载到内存然后将这个库映射到共享区此时这个库就可以被所有线程执行流看到了。此时有两个 ID 概念一个是在命令行上看到的 LWP一个是在用户层上看到的 tid。前者是在系统层面上供 OS 调度的后者是 pthread_create 获得的线程 ID它是一个用户层概念本质是一个地址就是 pthread 库中某一个起始位置也就是对应到共享区中的某一个位置。所以线程数据的维护全都是在 pthread 线程库中去维护的上图所示其中会包含每个线程的局部数据struct pthread 就是描述线程的 TCB线程局部存储可以理解是不会在线程栈上保存的数据我们在上面说过线程会产生各种各样的中间数据如上下文数据此时就需要独立的栈去保存它就是线程栈。而下图中拿到的 tid 就是线程在共享区中线程库内的相关属性的起始地址所以只要拿到了用户层的 tid就可以在库中找到线程相关的属性数据很明显 tid 和 LWP 是 1 : 1 的而主线程不使用库中的栈结构直接使用地址空间中的栈区称为主线程线。 实际上在很多 OS 在设计线程时都是用户级线程用户级线程就是把相关的属性数据放在用户层真正的调度还是得由一个相关的执行流来处理的这叫做 1 : 1这是 Linux 所采用的。当然在用户层只有一个执行流但 OS 为了完成你的这个任务可能会在内核层创建多个执行流去做的这就叫 1 : N。用户级线程是怎么和内核级线程关联的呢可以简单的理解成用户级线程只要把代码交给内核级线程代码就可以跑了创建用户级线程就是创建 LWP退出用户级线程就是退出 LWP再把库中的相关数据关掉只要在用户层的操作可以和内核层对应起来就行了就像白帮中一名警察派了一个卧底潜伏于黑帮然后警察指派任务给卧底警察是可以控制卧底的警察就是用户级线程卧底就是内核级线程它们的关系是 1 : 1 的内核级进程是与系统是强相关的如果让用户直接去用它倒也可以不过用户就要去了解它成本较高所以就需要存在用户级进程让用户更好的使用同时警察可以站在他的角度向老百姓解释的很清楚而卧底站在他的角度就解释不清楚。所以 Linux 中要有原生线程库的原因是 Linux 本身没有提供真正意义上的线程自然也就没有真正意义上的线程控制接口只能是轻量级进程来模拟而用户要操作轻量级进程就得向用户解释更多东西不是所有人都能理解这种现象的而用户作为一个东西被偷的人只需要你把东西找回来就行了也就是用户仅仅需要知道怎么操作线程就行了。所以需要存在一个用户级线程库才供用户使用就如同这个世界不是只有老百姓和恶人而需要有一个警察的角色。 6、线程终止
如果需要只终止某个线程而不终止整个进程可以有三种方法 从线程函数 return这种方法对主线程不适用从 main 函数 return 相当于调用 exit。 线程可以调用 pthread_ exit 终止自己。 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程。 1pthread_exit 函数
A. 接口介绍 线程终止。 retval用于传递线程的退出状态在主线程中pthread_join() 可以等待新线程结束并将新线程的退出状态存储在 tret 指针。 注意 pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的 不能在线程函数的栈上分配 因为当其它线程得到这个返回指针时线程函数已经退出了。 B. 代码 2pthread_cancel 函数
A. 接口介绍 取消一个执行中的线程。 B. 代码 __thread: 修饰全局变量结果就是让每一个线程各自拥有一个全局变量 —— 线程的局部存储。 7、线程等待
为什么需要线程等待 已经退出的线程其空间没有被释放仍然在进程的地址空间内。 创建新的线程不会复用刚才退出线程的地址空间。 1接口介绍
thread线程 ID。value_ptr它指向一个指针后者指向线程的返回值。 调用该函数的线程将挂起等待 直到 id 为 thread 的线程终止。 thread 线程以不同的方法终止 , 通过 pthread_join 得到的终止状态是不同的总结如下 如果 thread 线程通过 return 返回 value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉 value_ ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED。 如果 thread 线程是自己调用 pthread_exit 终止的 value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。 如果对 thread 线程的终止状态不感兴趣 可以传 NULL 给 value_ ptr 参数。 跟进程一样一般线程终止后必须进行等待由 main thread 进行等待。因为要防止内存泄漏资源浪费通过线程等待将曾经线程向进程在地址空间中申请的资源释放在线程这里一般是说如果不释放相关线程那么申请新线程时不会复用未释放的线程。保证主线程最后退出让新线程正常结束。获得新线程的退出码信息而 pthread_create 时调用 start_routine 新线程传入的参数是 void* 且返回的类型是 void*这样它就是一个通用接口也意味着新线程可以返回任意类型的数据此时 pthread_join 时就会被 retval 拿到。实际在底层就是 pthread_create 调用 start_routine 后start_routine 将线程退出码写到对应 PCB 中然后调用 pthread_join 时就可以从对应 PCB 中读取退出结果到 retval。
我们都知道不可能通过 fun 函数来把 10 拿出去的原因是因为它是值传递而应该地址传递。同样如果想在一个函数内部返回一个 void* 的值也很简单。pthread_create 中 start_routine 参数和返回值类型是 void*它是要支持通用接口而 pthread_join 中 retval 的类型是 void**然后 pthread_join 会通过你传入的线程 id去读取对应的 PCB 中的退出码信息因为退出码信息可能是不同类型的地址所以要用 void** 来接收retval 是输出型参数然后又由它返回 void* 到用户层。 四、分离进程 默认情况下新创建的线程是 joinable 的线程退出后需要对其进行 pthread_join 操作否则无法释放资源从而造成系统泄漏。 如果不关心线程的返回值join 是一种负担这个时候我们可以告诉系统当线程退出时自动释放线程资源。 1、pthread_detach
1接口介绍 用于创建线程它是一个回调函数。如果线程创建成功则会执行 start_routine编译和链接时需要引入 pthread。 thread输出型参数代表线程 id。 默认情况下新创建的线程是 joinable 的线程退出后需要对其进行 pthread_join 操作否则无法释放资源从而造成内存泄漏。如果不关心线程的返回值join 则是一种负担这个时候可以使用分离此时就告诉系统当线程退出时自动释放线程资源这就是线程分离的本质。joinable 和 pthread_detach 是冲突的也就是说默认情况下新创建的线程是不用 pthread_detach。就算线程被分离了也还是会和其它线程影响的因为它们共享同一块地址空间。 可以是线程组内其他线程对目标线程进行分离也可以是线程自己分离 joinable 和分离是冲突的一个线程不能既是 joinable 又是分离的。 注意没有线程替换这种操作但可以在线程中执行进程替换系列函数。这是因为新线程内部执行进程替换函数这看起来像是把新线程中的代码替换了但实际会把主线程中的代码也替换了因为主线程和新线程共享地址空间所以新线程内部进程替换后所有的线程包括主线程都会被影响。所以轻易不要在多线程中执行进程替换函数。 pthread_join 返回的是 22说明等待失败了然后返回进程终止。其实一个线程被设置为分离状态则该线程不应该被等待如果被等待了结果是未定义的至少一定会等待出错。 五、线程互斥 1、进程线程间的互斥相关背景概念 临界资源多线程执行流共享的资源就叫做临界资源。 临界区 每个线程内部访问临界资源的代码就叫做临界区。 互斥 任何时刻互斥保证有且只有一个执行流进入临界区访问临界资源通常对临界资源起保护作用。 原子性 不会被任何调度机制打断的操作该操作只有两态要么完成要么未完成。 1模拟抢票逻辑
或者也可以说 if 判断条件为真后代码可能并发切换到其它线程因为 usleep 漫长的等待过程中可能会有多个线程进入该临界区所以 tickets 就不是原子的。实际多线程在切换时极有可能出现因为数据交叉操作而导致数据不一致的问题那么 OS 中可能从内核态返回用户态的时候就会进行线程间切换。 A. 代码 如下图线程 A 将 tickets1000 从内存读到 CPU然后 ticket--。因为某种原因还没等线程 A 把 tickets 写回内存就被 CPU 剥离在剥离之前 CPU 上一定有线程 A 的临时数据或者说是上下文数据然后将上下文数据保存在线程内部紧接着 CPU 开始调度线程 B线程 B 将 tickets1000 从内存读到 CPU。因为线程 B 的运气比较好所以 tickets-- 了 500 次然后因为时间片到了就把 tickets500 写回内存然后 CPU 继续调度线程 A此时将线程 A 中保存的上下文数据恢复到 CPU 对应的寄存器中再继续执行第 3 步把 tickets999 写回物理内存。 B. 分析
a. 出现原子性问题
当第 1 个线程 if 判断成功后执行到 usleep陷入内核休眠并执行第 2 个线程以此类推。后来第 1 个线程醒来后输出并执行 tickets--以此类推。这样就有可能出现多个线程都在 if 判断中这样就有可能会出现减到负数的情况。要解决这种问题所以就引出了线程互斥。 b. 未出现原子性问题 2、互斥量 mutex
而要做到这三点就需要一把锁Linux 上提供的这把锁叫做互斥量。 大部分情况线程使用的数据都是局部变量变量的地址空间在线程栈空间内这种情况变量归属单个线程其他线程无法获得这种变量。 但有时候很多变量都需要在线程间共享这样的变量称为共享变量可以通过数据的共享完成线程之间的交互。 多个线程并发的操作共享变量会带来一些问题。 为什么可能无法获得争取结果 if 语句判断条件为真以后代码可以并发的切换到其他线程。usleep 这个模拟漫长业务的过程在这个漫长的业务过程中可能有很多个线程会进入该代码段。--ticket 操作本身就不是一个原子操作。 取出 ticket-- 部分的汇编代码操作并不是原子的而是对应三条汇编指令 load将共享变量 ticket 从内存加载到寄存器中。update更新寄存器里面的值执行操作。store将新值从寄存器写回共享变量 ticket 的内存地址。 要解决以上问题需要做到三点 代码必须要有互斥行为当代码进入临界区执行时不允许其它线程进入该临界区。如果多个线程同时要求执行临界区的代码并且临界区没有线程执行那么只能允许一个线程进入该临界区。如果线程不在临界区中执行那么该线程不能阻止其它线程进入临界区。 要做到这三点本质上就是需要一把锁Linux 上提供的这把锁叫互斥量。 3、互斥量的接口
mutex初始化或释放的锁。attr锁的属性设置nullptr即不管。 1初始化互斥量
A. 静态分配初始化不用 destroy B. 动态分配 2销毁互斥量 销毁互斥量需要注意 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。不要销毁一个已经加锁的互斥量。已经销毁的互斥量要确保后面不会有线程再尝试加锁。 3互斥量加锁和解锁 A. 互斥量加锁 调用 pthread_lock 时可能会遇到以下情况 互斥量处于未锁状态该函数会将互斥量锁定同时返回成功。发起函数调用时其他线程已经锁定互斥量或者存在其他线程同时申请互斥量但没有竞争到互斥量那么 pthread_ lock 调用会陷入阻塞执行流被挂起等待互斥量解锁。 B. 互斥量解锁 这里就完成了线程互斥的操作。线程是绝对可以随时被切换的但是线程是 “拿着锁” 走的线程不回来锁没法释放期间任何线程不在的时候当然也可以申请锁只不过不会成功。对其它拥有锁的线程执行临界区的时候要么不执行没有申请锁要么执行完毕释放锁这就间接完成了原子性。此外定义的全局 lock一定需要被所有线程先看到所以本质 lock 也是一种临界资源难道再加一层锁吗那谁又来保护它呢所以只需要保证 lockunlock 时是原子的即可也就是说在这过程中只会有一个线程对 lock 变量进行操作。 打开加解锁运行速度明显变慢了且因为互斥没有出现原子性问题 错误写法 注意这样做是有问题的因为一个线程已经锁定互斥量其它线程申请互斥量但没有竞争到互斥量那么 pthread_lock 调用时会陷入阻塞看到的现象就是执行流被挂起等待直到互斥量解锁。也就是说这里当一个线程执行到 tickets 0 时会走到 elsebreak 出循环此时加锁却没有解锁现在会一直处于阻塞状态其它线程都在等待。所以正确写法应该是上面运行的代码。 【改进抢票逻辑完整代码】 加锁就是串行执行吗 是的执行临界区代码一定是串行的。 加锁之后线程在临界区中会切换会有问题吗 不会有问题。虽然线程被切换了但是是在持有锁的情况下被切换的所以其它抢票线程要执行临界区代码也必须先申请锁但它是无法申请成功的。所以也不会让其他线程进入临界区也就保证了临界区中数据的一致性。 如果线程不申请锁而只是单纯的访问临界资源呢 不被允许这是错误的编码方式。 在没有持有锁的线程看来对它最有意义的两种情况
线程 1 没有持有锁。线程 1 释放锁此时没有持有锁的线程可以申请锁。 要访问临界资源那么每一个线程都必须实现申请锁每一个线程必须都得看到同一把锁且去访问它。锁本身就是一种共享资源那么为了保证锁的安全申请和释放锁必须都是原子的。 4互斥量实现原理探究
经过上面的例子我们已经能够意识到单纯的 i 或者 i 都不是原子的因为这有可能会出现数据不一致问题。为了实现互斥锁操作大多数体系结构都提供了 swap 或 exchange 指令该指令的作用是把 CPU 寄存器区和内存单元的数据相交换由于只有一条指令就保证了原子性即使是多处理器平台访问内存的总线周期也有先后一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。也就是说锁的原子性实现是由寄存器级别的如下是 lock 和 unlock 的伪代码以及理解
lock:movb $0, %alxchgb %al, mutexif(a1寄存器的内容 0){return 0;}else{挂起等待; } goto lock;
unlock:movb $1, mutex唤醒等待mutex的线程;return 0;
mutex 就是一个内存中的变量和定义 int 没太大区别假设 mutex 默认是 1。
线程 X 执行 movb $0%al 将 %al 寄存器清零。在这个过程中线程 Y 当然也有可能执行这条语句这没问题因为线程在剥离时会作上下文数据的保存线程在切换时就会把上下文数据保存于 TCB 中。 线程 X 执行 xchgb %almutex 将寄存器 %al 中的值 0 和内存的值 mutex:1 交换这意味着 mutex 里面的值被交换到了对应线程中的上下文中就相当于变成线程私有的。是的在汇编代码上只要一条语句就完成交换了因为它只是一条语句所以交换过程一定是原子的。具体 xchgb 是怎么完成的可以去了解了解它的汇编原理简单提一下就是在体系结构上有一个时序的概念在一个指定周期中去访问总线时汇编指令能被 CPU 接收到是因为汇编指令会在特定的时间放在总线上的而总线是可以被锁住的这在硬件上实现比较简单所以即使 xchgb 汇编翻译成二进制的时候是对应多条语句的但是它依旧不影响因为硬件级别把总线锁住了所以它虽然在汇编翻译成二进制时依旧是有多条语句的但是因为总线被锁住了所以不会出现原子性问题。 当线程 X 交换完后线程 Y 突然切换进来了在此之前就会把线程 X 的上下文数据保存于线程 X 的 TCB 中然后把线程 Y 中上一次在 TCB 中保存的上下文数据恢复到 CPU这里没有然后线程 Y 执行 movb $0%al 将 %al 寄存器清零执行 xchgb %almutex 将寄存器 %al 中的值 0 和内存的值 mutex:0 交换。所以线程 Y 再往下走就会 else 挂起等待。 再线程切换到线程 X把线程 Y 中的上下文数据保存于自己的 TCB然后线程 X 把自己在 TCB 中保存的上下文数据数据恢复到 CPU再执行 if就可以访问临界区中的代码了然后访问临界区成功并返回。 然后线程 X 去执行 unlock 中的 movb $1mutex 把内存中的 mutex 值又置为 1然后唤醒等待 mutex 的线程 Y成功返回这样线程 X 就完成了解锁unlock 也一定是原子的因为能执行 unlock 的一定是曾经 lock 过的所以 unlock 是不是原子性的已经不是重点了。最后切换到线程 Y将上下文数据恢复继续往下执行 goto lock将寄存器 %al 清零 然后将其与内存中的 mutex 值交换成功访问临界区资源返回然后把 mutex 值置为 1没有唤醒的 mutex 线程然后成功解锁。注意这里以不用管寄存器中的 %al:1因为下次在申请锁时会先把 %al 置 0。 注意 这里的 %al 是寄存器数据是线程的上下文数据代表的是线程私有。mutex 互斥锁本质就是内存中的一个内存空间空间里面的值是 1是可以被所有的线程读取并访问的。综上在交换的时候能保证只有一个 1。为什么一定要 swap/exchange 或者 xchgb 呢mov %al, mutex 不行吗一定不行虽然 mov 也是一条汇编但是它是拷贝和交换是不一样的拷贝会把 mutex 的值拷贝到寄存器中这样一来就不能保证只有一个 1 了其它线程依然可以在一个线程正在访问临界区时也访问临界区所以需要 swap/exchange 或者 xchgb 指令以保证在多线程执行时每个线程的上下文中只有一个 1这样就可以保证了原子了。 5可重入 线程安全
A. 重入和线程安全的概念 重入同一个函数被不同的执行流调用当前一个流程还没有执行完就有其他的执行流再次进入就称之为重入。一个函数在重入的情况下运行结果不会出现任何不同或者任何问题则该函数被称为可重入函数否则就是不可重入函数。 线程安全 多个线程并发同一段代码时不会出现不同的结果。常见对全局变量或者静态变量进行操作并且没有锁保护的情况下会出现该问题。 B. 常见的线程不安全的情况 不保护共享变量的函数。函数状态随着被调用状态发生变化的函数。返回指向静态变量指针的函数。调用线程不安全函数的函数。 C. 常见的线程安全的情况 每个线程对全局变量或者静态变量只有读取的权限而没有写入的权限一般来说这些线程是安全的。类或者接口对于线程来说都是原子操作。多个线程之间的切换不会导致该接口的执行结果存在二义性。 D. 常见不可重入的情况 调用了 malloc/free 函数因为 malloc 函数是用全局链表来管理堆的。调用了标准 I/O 库函数标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。可重入函数体内使用了静态的数据结构。 E. 常见可重入的情况 不使用全局变量或静态变量。不使用用 malloc 或者 new 开辟出的空间。不调用不可重入函数。不返回静态或全局数据所有数据都有函数的调用者提供。使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据。 F. 可重入与线程安全联系 函数是可重入的那就是线程安全的。函数是不可重入的那就不能由多个线程使用有可能引发线程安全问题。如果一个函数中有全局变量那么这个函数既不是线程安全也不是可重入的。 G. 可重入与线程安全区别 可重入函数是线程安全函数的一种。线程安全不一定是可重入的而可重入函数则一定是线程安全的。 如果将对临界资源的访问加上锁则这个函数是线程安全的但如果这个重入函数若锁还未释放则会产生死锁因此是不可重入的。 六、常见锁概念
1、死锁
1概念 死锁是一种常见的锁死锁是指在一组进程或线程中的各个进程或线程均占有不会释放的资源它们申请锁而不释放锁进而导致多个执行流互相等待的状态。 死锁通常发生在两个或多个进程或线程之间竞争资源的情况当一个进程或线程持有一个资源并请求另一个进程或线程持有的资源时如果另一个进程或线程也持有该进程所需的资源并请求第一个进程或线程的资源就有可能发生死锁。 举个例子小明和小红身上各有 5 毛钱想买 1 块钱的辣条小明对小红说把你的 5 毛钱给我我来买小红也对小明说把你的 5 毛钱给我我来买此时他们既想要申请对方的 5 毛钱又不想妥协释放自己的 5 毛钱这种状态就叫做死锁。 2死锁四个必要条件
只要产生了死锁一定有如下四个条件 互斥条件一个资源每次只能被一个执行流使用。本质上引入互斥就是为了保护临界资源由并行变成了串行保证了原子性。但也带了新的问题死锁。因为互斥只要存在就会产生申请资源阻塞的情况就有可能出现死锁请求与保持条件一个执行流因请求资源而阻塞时对已获得的资源保持不放。其实就是请求你的锁给我我的锁不释放不剥夺条件:一个执行流已获得的资源在末使用完之前不能强行剥夺。循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。一般而言你要我的资源而我要别人的资源是不会造成死锁的。而你要我的资源我也要你的资源就可能会造成死锁因为我们是竞争关系 3避免死锁 破坏死锁的四个必要条件其中一个。互斥条件很难被破坏因为它要求一次只有一个进程或线程使用资源一般也建议能不用锁就不用锁请求与保持就是释放相对应的锁资源不剥夺条件就是可以根据优先级设计一个算法就算一个执行流未使用完资源也可以被剥夺循环等待条件就是若干执行流之间在申请和释放锁资源时不要形成回路加锁顺序一致。避免锁未释放的场景。资源一次性分配。 4避免死锁算法
A. 死锁检测算法了解 死锁检测算法是一种利用进程状态的变化来识别死锁的方式能够提高系统的并发性和效率。 这种算法主要有两种形式 预防式它在进程申请资源时就进行干预以防止系统进入不安全状态事后处理它允许系统进入不安全状态然后通过某种算法检测并恢复。 具体来说死锁检测需要用某种数据结构来保存资源的请求和分配信息并提供一种算法利用上述信息来检测系统是否已进入死锁状态。如果检测到系统已经发生死锁可以采取相应的措施解除死锁如资源剥夺法、撤销进程法或进程回退法等。值得注意的是死锁检测通常在资源有向图也称为资源分配图上进行。 因此理解和掌握如何有效地在资源有向图上进行死锁检测和解除对于提高系统的性能和稳定性至关重要。 B. 银行家算法了解 银行家算法是由艾兹格·迪杰斯特拉在 1965 年为 T.H.E 系统设计的一种避免死锁产生的算法。这个算法不仅适用于操作系统给进程分配资源还可以用于其他需要进行资源分配和调度的场景。 该算法主要使用了四个数据结构可利用资源向量 Available、最大需求矩阵 Max、分配矩阵 Allocation 以及需求矩阵 Need。这些数据结构分别记录了系统中当前可用的资源数量、每个进程对资源的最大需求量、系统已经分配给每个进程的资源数量以及每个进程还需要的资源数量。 在实现的过程中银行家算法首先检查系统是否处于安全状态如果是则允许进程继续申请资源否则将拒绝进程的请求。如果系统进入不安全状态则需要采取相应的措施使系统返回到安全状态。 总的来说银行家算法是一种既简单又有效的死锁避免方法它通过合理的资源分配和调度来防止系统进入不安全状态从而避免了死锁的发生。 七、Linux 线程同步 很明显线程同步和线程互斥是相对的。对于前面的模拟抢票逻辑的实现当然可能存在某个线程竞争锁的能力特别强每次申请都是它优先申请到锁资源其它线程没有机会拥有锁我们称这种问题为饥饿问题这样就会出现该线程一直在循环进行申请锁、检测抢票、释放锁即使票已经没有了因为释放锁后并没有 break 出循环。 饥饿问题本身并没有错只是说不合理。就好比如你从墙边拿了钥匙去单间自习室中自习一会出来了刚把钥匙挂在墙边看到有很多人在排队你想着好不容易抢到了单间自习室于是你刚放下钥匙又拿起了钥匙进去自习一会又出来放钥匙了看到人还是很多心想把明早的也自习算了于是又拿着钥匙去自习了反反复复。你本质能反反复复的进去自习是因为你离钥匙最近理论上虽然没问题但却不合理。所以你作为自习室的管理者就规定了如果从单间自习室出来把钥匙挂在墙上后不能立即再申请钥匙如果你要再次申请就必须排到队列的尾部。 排队的本质就是让我们获取锁在安全的前提下按照某种顺序进行申请和释放这就叫做线程同步的过程。同步不是严格意义上解决数据正确或错误的问题而是解决不合理的问题。所以我们经常会一起听到互斥和同步这两个概念因为光有互斥还无法保证执行流进行合理的临界资源访问。 1、条件变量
1概念 条件变量是一个原生线程库提供的一个描述临界资源状态的对象或变量。 在抢票时发现票已经售完了就不应该再申请锁了而应该等有票了再申请。比如说中午你问你妈有没有吃的你妈说没有过了两秒你又问你妈有没有吃的你妈又说没有反反复复这样当然没错只是不合理。实际上你妈告诉你没有吃的的时候就等同于临界资源中没有票了而合理的是你不要着急的去问你妈或申请锁而应该跟你妈说等会有吃的了叫我一声然后你就在一旁等待直到你妈唤醒你然后你再去询问。所以需要条件变量来描述临界资源状态之前之所以不断的轮询申请锁、检测、释放锁本质就是我们并不知道临界资源的状态。
如下图当右边正在放苹果时左边的来拿了此时左侧的人不一定能拿到苹果因为正在放这个动作包含了放前、放中、放后所以这就叫做二义性问题。所以使用锁来解决这种问题无论你要放还是要拿苹果都需要加锁其过程是原子的这样就解决了二义性问题。那么问题又来了拿苹果的比较磨蹭放苹果的又比较利索然后放苹果的加锁再放苹果接着解锁假设两个人都是瞎子那放苹果的也并不知道苹果有没有被拿苹果的拿走所以放苹果的又开始加锁然后检测到苹果没有被拿走接着又释放锁拿苹果的人实在是太慢了放苹果的人反反复复加锁、检测、解锁了很多次依旧检测到盒子里的苹果没有任何有效的动作放苹果的人的这样一个周期很快而导致拿苹果的人就算要去拿了也竞争不过放苹果的人。以上帝视角来看放苹果的人就是在不断的申请释放而拿苹果的人想拿却竞争不过放苹果的人。反复强调了这种现象当然没有错只是不合理所以合理的是放苹果的人加锁、放苹果、解锁、然后敲一下铃铛就去睡觉了此时拿苹果的人就知道了即使拿苹果的人很慢但他一定可以拿到苹果一定时间后拿苹果的人开始加锁、拿苹果、解锁敲铃铛也去睡觉了那么这个时候放苹果的人也知道拿苹果的人把苹果拿走了放苹果的人就可以继续的往盒子里放苹果了这里的铃铛被称为条件变量铃铛就是描述盒子的状态它对应的就是临界资源所以条件变量就是描述临界资源的状态。所以有了条件变量就可以不用频繁的通过申请和释放锁的方式以达到检测临界资源的目的。 当一个线程互斥地访问某个变量时它可能发现在其它线程改变状态之前它什么也做不了。 例如一个线程访问队列时发现队列为空它只能等待只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。 2、同步概念与竞态条件 同步在保证数据安全的前提下让线程能够按照某种特定的顺序访问临界资源从而有效避免饥饿问题叫做同步。竞态条件因为时序问题而导致程序异常我们称之为竞态条件。在线程场景下这种问题也不难理解。 3、条件变量函数
1接口介绍
A. 初始化 初始化 cond并设置属性 attr。 cond要初始化的条件变量。attrNULL。 B. 销毁 销毁条件 cond。 C. 等待条件满足 timedwait指定的时间范围内等待条件变量下的线程少用。wait指定的条件变量下进行等待一定要入 cond 的队列mutex 用于在锁中阻塞挂起时会自动释放锁唤醒时会自动获取锁后面生产者消费者中详细介绍。 cond要在这个条件变量上等待。mutex互斥量后面详细解释。 D. 唤醒等待 signal唤醒指定条件变量下等待的一个线程。broadcast唤醒所有在该条件变量下等待的线程少用。 4、为什么 pthread_ cond_ wait 需要互斥量? 条件等待是线程间同步的一种手段如果只有一个线程条件不满足一直等下去都不会满足所以必须要有一个线程通过某些操作改变共享变量使原先不满足的条件变得满足并且友好的通知等待在条件变量上的线程。 条件不会无缘无故的突然变得满足了必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。 按照上面的说法我们设计出如下的代码先上锁发现条件不满足解锁然后等待在条件变量上不就行了如下代码
// 错误的设计
pthread_mutex_lock(mutex);
while (condition_is_false) {pthread_mutex_unlock(mutex);//解锁之后等待之前条件可能已经满足信号已经发出但是该信号可能被错过pthread_cond_wait(cond);pthread_mutex_lock(mutex);}
pthread_mutex_unlock(mutex);
由于解锁和等待不是原子操作所以在调用解锁之后pthread_ cond_ wait 之前如果已经有其他线程获取到互斥量摒弃条件满足发送了信号那么 pthread_ cond_ wait 将错过这个信号可能会导致线程永远阻塞在这个 pthread_ cond_ wait所以解锁和等待必须是一个原子操作。 int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后会去看条件量是否等于 0。如果等于就把互斥量变成 1直到 cond_ wait 返回把条件量改成1把互斥量恢复成原样。 5、条件变量使用规范
1等待条件代码
pthread_mutex_lock(mutex);
while (条件为假pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(mutex); 2给条件发送信号代码
pthread_mutex_lock(mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(mutex); 【代码】