目录

1. 线程概念与模型

2. 线程实现方式

2.1 内核级线程

2.2 用户级线程

2.3 组合式线程

3. 线程标识

3.1 概念与实现

3.2 相关函数

3.2.1 pthread_equal

3.2.2 pthread_self

3.3 示例与说明:PID与TID关系

4. 线程启动与终止

4.1 线程启动相关函数

4.2 示例与说明:线程启动与传参

4.3 线程终止方式

4.3.1 主动终止

4.3.2 被动终止

4.4 线程终止相关函数

4.4.1 pthread_exit

4.4.2 pthread_cancel

4.5 线程回收相关函数

4.6 示例与说明:线程返回值回收

5. 线程清理

5.1 相关函数

5.1.1 pthread_cleanup_push

5.1.2 pthread_cleanup_pop

5.2 示例与说明:线程清理函数

6. 进程 & 线程启动和终止方式比较

7. 线程同步与互斥

7.1 同步与互斥的概念

7.1.1 产生数据一致性问题的场景

7.1.2 线程互斥

7.1.3 线程同步

7.2 线程互斥:互斥锁

7.2.1 互斥问题示例:银行账户操作

7.2.2 互斥锁概述

7.2.3 互斥锁类型

7.2.4 互斥锁相关函数

7.2.5 示例与说明1:互斥锁使用

7.2.6 示例与说明2:给未上锁的互斥锁解锁

7.2.7 避免死锁

7.2.8 带超时的互斥锁操作

7.3 线程互斥:读写锁

7.3.1 读写锁引入

7.3.2 读写锁类型

7.3.3 读写锁相关函数

7.3.4 示例与说明1:读写锁使用

7.3.5 示例与说明2:给未上锁的读写锁解锁

7.4 线程同步:条件变量

7.4.1 条件变量概述

7.4.2 条件变量相关函数

7.4.3 示例与说明1:条件变量的使用

7.4.4 示例与说明2:读者-写者问题

7.5 线程同步:信号量

7.5.1 信号量概述

7.5.2 信号量相关函数

7.5.3 示例与说明1:信号量实现互斥

7.5.4 示例与说明2:信号量实现同步

7.6 其他

7.7 增补:屏障与并行计算

7.7.1 屏障(barrier)概述

7.7.2 屏障相关函数

7.7.3 并行计算示例

8. 线程的状态转换

9. 线程控制

9.1 线程对象属性概述

9.2 线程属性

9.2.1 线程属性类型

9.2.2 线程属性相关函数

9.2.3 示例与说明1:分离属性

9.2.4 示例与说明2:线程栈属性

9.3 互斥锁属性

9.3.1 互斥锁属性类型

9.3.2 互斥锁属性相关函数

9.3.3 示例与说明:互斥锁类型属性


1. 线程概念与模型

说明1:进程的资源分配角色

① 进程是资源管理的最小单位

② 进程由一组相关资源构成,包括地址空间(代码段 & 数据段)、打开的文件等各种资源

说明2:线程的处理机调度角色

① 线程是程序执行的最小单元

② 线程描述在进程资源环境中的指令流执行状态

③ 在引入线程的操作系统中,线程是独立调度的基本单位

说明3:进程与线程的资源

① 线程是进程的一条执行路径,每个线程共享其所附属进程的所有资源,包括数据段、代码段、打开的文件、内存页面、信号标识及动态分配的内存等(共享资源)

② 线程则拥有自己独立的栈和CPU寄存器状态(独占资源)

其中独立的栈 + PC寄存器(指向进程代码段中的线程运行函数)就构成了一条执行路径

说明4:进程与线程的关系

① 线程是属于进程的,运行在进程空间内

② 同一进程所产生的线程共享同一用户内存空间。当进程退出时,该进程所产生的线程都会被强制退出并清除

③ 一个进程至少需要一个线程作为其指令执行体(进程启动后至少有一个主线程);然后可以在已运行的线程中创建子线程(一般是由主线程创建其他线程)

说明5:多线程编程的优点

① 简化编程模式

通过为每种事件类型分配单独的处理线程(这些线程之间可以通过线程同步机制配合),可以简化处理异步事件的代码

② 减小系统开销

可以减小进程启动 / 进程切换 / 进程间通信的开销

③ 改善吞吐量和响应时间

在多核处理器上使用多线程编程,可以调度线程在不同的CPU上运行,充分利用多核处理器的优势

即使多线程程序在串行化任务时不得不阻塞,由于某些线程在阻塞时还有另外一些线程可以运行,所以多线程程序在单核处理器上还是可以改善吞吐量和响应时间

2. 线程实现方式

线程根据其实现的不同方式可以分为内核级线程(Kernel Supported Threads, KST)、用户级线程(User Level Threads, ULT)、组合式线程(KST / ULT)

2.1 内核级线程

说明1:内核创建与管理

无论是用户空间进程中的线程还是系统进程的线程,均由内核创建、撤销、切换。线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口

说明2:一对一映射

内核线程驻留在内核空间,他们是内核对象(即内核可以感知到线程的存在)。有了内核线程,每个用户线程被映射或绑定到一个内核线程。

用户线程在其生命期内都会绑定到该内核线程,一旦用户线程终止,两个线程都将离开系统

说明3:内核级线程的优点

① 在多核处理器中,内核可以同时调度同一个进程中的多个线程并行执行

② 如果进程中的一个线程被阻塞,内核可以调度进程中的其他线程运行

③ 内核本身可以使用多线程技术,提高系统的执行效率

说明4:内核级线程的缺点

当线程切换时,由于用户线程是在用户态运行的,因此需要从用户态切换到内核态进行,导致开销较大

2.2 用户级线程

说明1:应用程序创建与管理

用户级线程仅存在于用户空间中,此类线程的创建、撤销、切换以及线程间的同步与通信都无需利用系统调用来实现,而是利用线程库实现(线程库也运行在用户空间)

说明2:CPU时间片分配

由于内核无法感知用户级线程的存在,因此时间片是以进程为单位划分的。此时是内核先调度进程,而后线程库再调度不同的用户级线程

说明3:用户级线程的优点

① 可以在不支持线程的操作系统中实现多线程功能

② 因为无需在用户态和内核态之间切换,线程管理的开销小于内核线程(e.g. 线程切换的效率更高)

③ 调度算法可以是进程专用的。在不干扰系统调度的情况下,不同进程可以根据自身需要选择不同的调度算法对自己的线程进行管理和调度,而与操作系统的底层调度算法无关

说明4:用户级线程的缺点

① 因为在使用用户级线程时以进程为调度单位,所以当进程中的某个线程因系统调用而被阻塞时,整个进程都会被阻塞

② 同一个进程中的线程只能在同一个处理机下分时复用

注意:用户级线程多见于一些历史悠久的操作系统,例如UNIX

2.3 组合式线程

说明1:实现方式

① 线程的创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行

② 一个进程中的多个用户线程被映射到一些(<= 用户线程的数量)内核线程上

注意:对于如何将用户线程绑定到内核实体,POSIX库赋予了程序员一定权限,这种绑定设置其实也会影响线程的调度

说明2:POSIX线程模型

① POSIX线程库采用组合式线程模型

② POSIX线程模型中包括两级调度:线程级和内核实体级

线程级与用户级线程类似,由线程库在用户空间调度;内核实体则由内核调度。由线程库决定需要多少内核实体,以及他们如何映射

在实现方式上,POSIX线程模型引入线程调度竞争范围(thread-scheduling contention scope)的概念,这个概念赋予程序员控制权,使他们能够控制怎样将内核实体映射为线程

带有PTHREAD_SCOPE_PROCESS属性的线程与他所在进程中的其他线程竞争处理器资源

带有PTHREAD_SCOPE_SYSTEM属性的线程在全系统范围内竞争处理器资源

注意:具体设置细节见后文线程属性部分

说明3:轻型进程(Light Weight Process,LWP)

要理解用户线程线程绑定,就必须说明轻型进程的概念,系统对线程资源的分配、对线程的控制是通过LWP实现的

顺便说明一下,LWP是实体,虽然具体的实现细节我还不知道~~

① 轻型进程的位置

轻型进程位于用户层和系统层之间。面向用户层,每个LWP可以控制一个或多个线程;面向系统层,每个LWP与一个内核线程绑定

这样通过LWP将用户线程与内核线程连接起来,用户线程可以通过LWP访问内核;而内核看到的总是多个LWP,而看不到用户线程(实现了用户线程与内核的隔离)

当多个用户线程复用一个LWP时,只有当前连接到LWP上的线程才能和内核通信,其余线程或者阻塞,或者等待LWP(这也就是POSIX线程库中的线程级调度)

② 用户线程与LWP的绑定

默认情况下,启动多少LWP、哪些LWP控制哪些用户线程是由系统控制的,这种情况称为非绑定

而绑定则是指将某个用户线程绑定到一个LWP上,绑定的用户线程可以保证在需要时总有一个LWP可用。POSIX中使用PTHREAD_SCOPE_PROCESS和PTHREAD_SCOPE_SYSTEM参数设置pthread_attr_setscope属性时,就是指定用户线程与LWP的绑定关系

3. 线程标识

3.1 概念与实现

① 每个进程内部不同线程都有自己的唯一标识(ID),线程标识只在他所属的进程环境中有效

个人:线程标识仅在他所属的进程环境中有效,说明该机制是在pthread库中实现的,而非在内核中实现的

② 在POSIX线程库中使用pthread_t数据类型表示,具体实现因操作系统而异,在我目前使用的Ubuntu 16.04中实现如下

注意:pthread_t不同实现方式导致的最坏结果就是不能以可移植的方式打印TID

3.2 相关函数

3.2.1 pthread_equal

所需头文件

#include <pthread.h>

函数原型

int pthread_equal(pthread_t tid1, pthread_t tid2);

函数参数

tid1 & tid2:待比较的TID

函数返回值

相等返回非0数值;否则返回0

3.2.2 pthread_self

所需头文件

#include <pthread.h>

函数原型

pthread_t pthread_self(void);

函数参数

函数返回值

调用线程的TID

3.3 示例与说明:PID与TID关系

在主线程和子线程中分别打印PID和TID,可见二者PID相同,TID不同。这就印证了这2个线程属于同一个进程

扩展:此处在子线程中打印TID时使用了pthread_self函数而非使用全局变量ntid,虽然主线程将子线程的TID存储在ntid中,但子线程并不能安全地使用他

这是因为如果子线程在主线程调用pthread_create函数返回之前就运行了,那么子线程看到的可能是未经正确设置的ntid内容(这其实也体现出只要是单个资源需要在多个用户间共享,就必须处理一致性问题)

4. 线程启动与终止

4.1 线程启动相关函数

所需头文件

#include <pthread.h>

函数原型

int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr,

void *(*start_rtn)(void *), void *restrict arg);

函数参数

tidp:存储新建线程TID的指针

attr:线程属性指针,NULL表示使用缺省属性创建线程

start_rtn:线程执行函数,参数和返回值均为void *

arg:传递给start_rtn的参数

函数返回值

成功返回0;否则返回错误编号

说明1:线程执行函数

新创建的线程从start_rtn函数的地址开始运行,该函数只有一个void *类型的参数,如果要向start_rtn函数传递的参数多余1个,需要把这些参数组织为结构体,然后传递结构体变量地址

说明2:线程调度关系

线程创建时不能保证是新创建的线程还是调用线程先运行

说明3:errno设置

与其他的POSIX函数不同,pthread_create调用失败时不会设置errno。每个线程提供errno的副本,只是为了与使用errno的现有函数兼容

在线程编程中,从函数中返回错误码更为清晰,而不是依赖那些随着函数执行不断变化的全局状态(还是数据一致性的问题~~)

4.2 示例与说明:线程启动与传参

topic:线程执行函数传参 / 线程函数的可重入性 / 线程调度

说明1:线程函数传参

由于此处向线程函数传递的参数较多,所以将他们组织为RaceArg结构体,然后向线程函数传递结构体变量的指针

说明2:线程函数的可重入性

如上文所述,线程是进程中的一条执行路径(由PC寄存器标识)且拥有各自的栈(由SP寄存器标识),所以rabbit和turtle两个线程虽然运行函数相同(表现为PC指向从代码段的相同位置开始)但是使用的栈是不同的(表现为2个线程的SP指向不同)

因此线程中的局部变量是不共享的;但进程中的全局变量是共享的(在.data / .bss段)。这种共享是不安全的,所以才引入了线程对共享资源访问的互斥问题。

注意:虽然静态局部变量也在.data / .bss段,但是由于作用域的限制(使用static修饰局部变量只是修改其生存期,并未修改作用域和链接属性)实际不会在线程间共享

说明3:drand48函数

drand48函数的原型为:

double drand48(void);

该函数会返回[0.0, 1.0) 范围内的浮点数,本示例中用于获得随机休眠的时间(以微秒为单位)

4.3 线程终止方式

4.3.1 主动终止

① 线程函数中调用returen语句

② 线程函数调用pthread_exit函数

4.3.2 被动终止

① 线程可以被同一进程中的其他线程调用phread_cancel函数取消

4.4 线程终止相关函数

4.4.1 pthread_exit

所需头文件

#include <pthread.h>

函数原型

void pthread_exit(void *retval);

函数参数

retval:线程结束时的返回值,可通过pthread_join函数接收

函数返回值

说明:在线程执行函数中不能使用exit函数退出,exit函数的作用是使当前进程终止。通常一个进程包含多个线程,如果调用了exit函数,该进程中的所有线程都会随之结束

4.4.2 pthread_cancel

所需头文件

#include <pthread.h>

函数原型

int pthread_cancel(pthread_t tid);

函数参数

tid:要取消的线程TID

函数返回值

若成功,返回0;否则返回错误编号

说明:pthread_cancel取消线程的行为

① pthread_cancel函数会使得由tid标识的线程行为表现为以PTHREAD_CANCELED参数调用了pthread_exit函数

在pthread.h中,PTHREAD_CANCELED宏定义如下,

② pthread_cancel函数只是提出请求,并不会等待线程终止

③ 被终止线程可以使用pthread_setcancel / pthread_setcanceltype函数控制是否及如何被取消(后文内容)

4.5 线程回收相关函数

所需头文件

#include <pthread.h>

函数原型

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

函数参数

thread:要回收的线程TID

rval_ptr:当设置为非NULL时,用来接收被等待线程的返回值(后文详述)

函数返回值

成功返回0;否则返回错误编号

说明1:等待线程,释放资源

通常在线程退出后,该线程占用的资源不会随线程结束而释放,需要调用pthread_join函数等待线程结束(调用者阻塞,直至等待的线程结束或被取消),并回收其资源。该行为类似于进程编程中调用wait函数回收子进程

注:在默认情况下,线程的终止状态会保存,直到对该线程调用pthread_join函数

说明2:设置分离属性

可以在启动线程时设置其分离属性,这样线程在结束后系统会自动释放其所占用的资源。需要注意的是,如果线程处于分离状态,pthread_join调用会失败并返回EINVAL

注:分离属性在网络通讯中使用较多

说明3:谁来等待~

虽然一般都是一个线程的启动者调用pthread_join函数回收线程资源,但是从函数本身的设计而言,同进程的其他线程均可以等待

4.6 示例与说明:线程返回值回收

topic:pthread_join函数rval_ptr参数的使用

根据上文,线程结束时可以通过return或pthread_exit返回一个值,而pthread_join函数可以通过rval_ptr参数带回该值,进而获得线程的退出状态。要完全理解其中的函数调用方式,需要理清下面3个概念:

① 变量的特性

C语言中的变量可以理解为,变量 = 值 + 类型

所谓"值",就是变量所占内存中存储的内容(在计算机中均以二进制位存储)

所谓"类型",就是C语言中对值的使用 / 解释方式,C语言中的强制类型转换(cast)转换的就是变量的类型,也就是转换对变量值的解释方式

注意1:此处对变量的理解没有提到变量名,这是因为在汇编层面,根本就不存在变量名。在对源代码进行编译后,变量名会被链接地址所取代

注意2:指针类型变量的默认解引用(函数行为)

从本质上说,指针类型变量中存储的也只是一个值,与普通变量没有区别。只是在对其进行解引用时,会体现指针类型变量的基类型特性。但是在C语言的某些应用场合,会对指针类型变量进行默认解引用,这是导致大家理解困难的一个原因

假设有如下指针类型变量,

char *str = "abc";

当调用printf("%s\n", str)函数打印该字符串内容时,实际会对str指针变量进行一次默认解引用,也就是会自动访问str变量中存储的地址值指向的内容

其实对这类行为的正确理解是,此处的解引用是库函数进行的,对指针类型变量的使用与其他变量并无本质差异

② 线程结束返回一个值

虽然线程函数的返回值类型为void *,但是根据第1点的理解,线程返回的就是一个值(这点与pthread_create函数的void *arg参数相同)。而对这个值是直接使用(直接变量)还是解引用使用(指针类型变量),则完全由程序结构决定,与系统调用的接口无关(系统调用仅负责实现这个值的传递)

注意:void *类型

在C语言中,定义为void *类型的参数 & 返回值是用于强调此处重要的是值,而对值的解释(也就是变量类型)则依赖程序结构的定义

个人觉得void *类型的提出,是C语言中的一大创举。虽然在使用void *的地方也可以定义带具体基类型的指针(e.g. int *)然后再在函数内部进行转换。但是定义为void *类型后可以向用户强调,此处重要的就是传递进来的值

③ pthread_join接收线程返回值

在pthread_join函数中,用于接收线程返回值的参数类型为void **类型,这点与线程返回void *类型的值是匹配的

在pthread_join函数内部进行的赋值过程可模拟如下,

int pthread_join(pthread_t thread, void **rval_ptr)
{// 其他操作// 获得子线程的返回值x(void *类型)*rval_ptr = x;// 其他操作
}

也就是说pthread_join函数在意的是如何将线程返回值带出来,而对该值的解释(也就是类型)则由程序员决定

下面就通过几个实例说明线程返回值的传递与获取~~

解析:此处线程返回的是参数结构体中2个成员之和,该值在返回时被强制转换为void *类型

在使用pthread_join函数接收线程返回值时,本质上就是传递了result变量的地址,那么pthread_join函数会将线程返回值写入result变量中

这个实例完全展示了上文中对变量 = 值 + 类型的概念解析~~result变量中存储的是线程返回值,对该值的类型解释则由程序员规定

在理解了向pthread_join函数传递的实际就是一个变量的地址(必须是左值,才能容纳结果)后,我们就可以修改对pthread_join函数的调用方式

上面2个实例中线程返回的值都是直接变量,下面提供一个返回指针类型变量的实例

解析:此处线程返回的是形式参数结构体的地址,需要注意的是,此时需要保证返回的内存地址在完成pthread_join函数调用后依然有效(与函数不能返回指向局部变量的指针要求一致)

5. 线程清理

5.1 相关函数

线程可以设置他退出时需要调用的函数,这些函数称为线程清理处理程序(thread cleanup handler)

一个线程可以建立多个清理处理程序,这些程序被记录在栈中,因此他们的调用顺序与注册顺序相反

5.1.1 pthread_cleanup_push

所需头文件

#include <pthread.h>

函数原型

void pthread_cleanup_push(void (*rtn)(void *), void *arg);

函数参数

rtn:注册的清理函数

arg:传递给清理函数的参数

函数返回值

5.1.2 pthread_cleanup_pop

所需头文件

#include <pthread.h>

函数原型

void pthread_cleanup_pop(int execute);

函数参数

execute:pthread_cleanup_pop函数均会删除pthread_clean_push建立的清理函数,如果

execute参数非0,则会调用该清理函数(否则不调用)

函数返回值

5.2 示例与说明:线程清理函数

说明1:成对调用

由于pthread_cleanup_push & pthread_cleanup_pop函数可以实现为宏,所以必须配对使用,以保证语法上的括号配对

在我使用的Ubuntu 16.04中,这2个函数就被实现为宏,因此必须成对调用

说明2:线程处理函数被调用的触发时机

① 调用pthread_exit函数

注意:只有调用pthread_exit函数终止线程才会触发线程处理函数被调用,使用return返回是不会触发的

a. 调用pthread_exit函数

b. return返回

② 响应pthread_cancel的取消请求

③ 用非零execute参数调用pthread_cleanup_pop函数(如本节示例)

说明3:成对调用中return返回的风险

在《UNIX环境高级编程》中提供了一个示例,即在pthread_cleanup_push & pthread_cleanup_pop之间调用returen语句 / pthread_exit函数返回

但是该书同时也提到,如果用宏实现,宏会把某些上下文存放在栈上,而在调用之间返回会改写栈的内容,因此在某些操作系统中(e.g. FreeBSD)会出错

个人:由于这对函数的使用限制颇多,且平台差异性较大,所以并不常见于代码~~(非常不常见)

6. 进程 & 线程启动和终止方式比较

进程原语

线程原语

描述

fork

pthread_create

创建新的控制流

exit / _exit / return

pthread_exit / return

从现有的控制流中退出

wait / waitpid

pthread_join

从控制流中得到退出状态

atexit

pthread_cleanup_push

注册在退出控制流时调用的函数

getpid

pthread_self

获取控制流的ID

abort

pthread_cancel

请求控制流的非正常退出

7. 线程同步与互斥

7.1 同步与互斥的概念

7.1.1 产生数据一致性问题的场景

只要同时满足如下2个条件,就必须处理数据一致性问题:

① 多个线程访问同一个资源

② 至少有一个线程是写操作

7.1.2 线程互斥

① 线程互斥是指多个线程访问同一资源时相互排斥

② 解决互斥的方法

a. 互斥锁

b. 读写锁

c. 线程信号量

7.1.3 线程同步

① 线程同步是一个宏观的概念,在微观上包含2个方面:

a. 线程的相互排斥

b. 线程执行顺序的约束条件

② 解决同步的方法

a. 条件变量

b. 线程信号量

7.2 线程互斥:互斥锁

7.2.1 互斥问题示例:银行账户操作

假设银行账户余额为10000元,2个线程同时操作该账户,从中取款10000元,如果不进行互斥操作可能导致数据不一致问题。模拟代码如下:

注意:此处的sleep(1)就是为了构成竞态

由于取款操作未进行互斥处理,可能导致如下结果,即2个线程均显示取款成功

7.2.2 互斥锁概述

互斥锁(mutex)通过简单的加锁方法来控制对共享资源的访问,在访问共享资源前进行加锁操作,在访问完成后进行解锁操作

互斥锁的操作特性如下:

① 对互斥锁加锁后,任何其他试图再次对该互斥锁加锁的线程都会被阻塞,直到当前线程释放该互斥锁

② 如果释放互斥锁时有一个以上线程阻塞,那么所有在锁上阻塞的线程都会变成可运行状态,第一个变为运行状态的线程就可以对互斥锁加锁,其他线程会看到互斥锁依然处于上锁状态,只能继续阻塞等待

注意:互斥锁的特征就是只有加锁 & 不加锁两种状态,每次只允许一个线程持有互斥锁

7.2.3 互斥锁类型

在POSIX线程库中使用pthread_mutex_t类型表示互斥锁,在Ubuntu 16.04上将其实现为一个联合体

POSIX线程库提供了一个静态定义互斥锁的宏PTHREAD_MUTEX_INITIALIZER,从该宏的定义可以理解其对pthread_mutex_t中各字段的初始化

注意:使用PTHREAD_MUTEX_INITIALIZER宏定义的是默认属性的互斥锁

7.2.4 互斥锁相关函数

① pthread_mutex_init

所需头文件

#include <pthread.h>

函数原型

int pthread_mutex_init(pthread_mutex_t *restrict mutex,

const pthread_mutexattr_t *restrict attr);

函数参数

mutex:待初始化的互斥锁指针

attr:互斥锁属性,NULL表示使用默认属性初始化互斥锁

函数返回值

若成功,返回0;否则,返回错误编号

② pthread_mutex_destroy

所需头文件

#include <pthread.h>

函数原型

int pthread_mutex_destroy(pthread_mutex_t *mutex);

函数参数

mutex:待销毁的互斥锁指针

函数返回值

若成功,返回0;否则,返回错误编号

③ pthread_mutex_lock / trylock / unlock

所需头文件

#include <pthread.h>

函数原型

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

函数参数

mutex:要上锁 / 解锁的互斥锁指针

函数返回值

若成功,返回0;否则,返回错误编号

说明:pthread_mutex_trylock函数是pthread_mutex_lock函数的非阻塞版本,当无法上锁时,不会阻塞调用进程而是返回EBUSY

因此要注意检查pthread_mutex_trylock函数的返回值,以判断此次上锁是否成功

7.2.5 示例与说明1:互斥锁使用

topic:使用互斥锁解决互斥问题

为解决多用户对同一账户操作的互斥问题,在描述账户的结构体中增加互斥锁,在取款操作时通过该互斥锁保护共享资源(即代表账户的结构体变量)

建议:编程时将互斥锁及其要保护的共享资源绑定在一起

这样在多用户操作同一银行账户时,可保持共享数据的一致性

7.2.6 示例与说明2:给未上锁的互斥锁解锁

如上图所示,我们尝试对未上锁的互斥锁进行解锁操作,从结果看并不会返回错误。这点对于理解后续pthread_cond_wait函数的调用有帮助,可以用于解释将未上锁的互斥锁传递给phread_cond_wait函数时的行为

7.2.7 避免死锁

① 造成死锁的2种情况

a. 线程对同一个互斥锁加锁2次 // 这种错误着实不常见~~

b. 两个线程相互请求另一个线程上锁的资源(即两个线程试图同时占用两个资源,并按不同的顺序锁定相应的共享资源)

② 设计上锁顺序,避免死锁

在同时需要2个互斥锁时,总是以相同的顺序加锁

③ 性能平衡

设计多线程程序时需要平衡互斥锁的粒度以及加解锁的开销

互斥锁粒度

优点

缺点

粒度太粗

互斥锁数量少

互斥锁控制简单,不易出现死锁

会出现很多线程阻塞等待相同的

粒度太细

线程阻塞减少

互斥锁数量多

互斥锁控制复杂,容易出现死锁

加锁 / 解锁开销增加

7.2.8 带超时的互斥锁操作

所需头文件

#include <pthread.h>

#include <time.h>

函数原型

int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,

const struct timespect *restrict tsptr);

函数参数

mutex:要上锁的互斥锁指针

tsptr:等待的绝对时间

函数返回值

若成功,返回0;否则,返回错误编号

说明1:调用pthread_mutex_timedlock获取互斥锁时,如果在指定的时间未能上锁,将会返回错误码ETIMEDOUT,因此需要判断该函数的返回值,以判断此次上锁是否成功

说明2:如何设置超时时间

由于传递给phtread_mutex_timedlock函数的是等待的绝对时间,即在该时间点之前进行等待,所以一般先获取当前时间,然后在当前时间的基础上进行设置,示例如下,

7.3 线程互斥:读写锁

7.3.1 读写锁引入

引入读写锁的目的是改善互斥锁的读并发性能,互斥锁只有加锁 & 不加锁两种状态,而读写锁为三种状态:

① 读模式下加锁

② 写模式下加锁

③ 不加锁

具体的上锁及阻塞策略如下表所示:

第1次上锁

第2次上锁

第2次上锁线程结果

写锁

读锁

阻塞

写锁

写锁

阻塞

读锁

读锁

成功

读锁

写锁

阻塞

说明1:为确保共享数据的一致性,只要有写者就会导致阻塞,因此读写锁适用于读的次数远多于写的情况

说明2:避免读模式锁长期占用

在实现中,为避免读模式锁长期占用,线程库可能会采取如下策略:

当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁,读写锁除了会阻塞该写线程,还会阻塞随后的读模式请求

7.3.2 读写锁类型

在POSIX线程库中使用pthread_rwlock_t类型表示读写锁,在Ubuntu 16.04上将其实现为一个联合体

POSIX中同样提供了静态定义读写锁的宏PHTREAD_RWLOCK_INITIALIZER

7.3.3 读写锁相关函数

① pthread_rwlock_init / destroy

所需头文件

#include <pthread.h>

函数原型

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,

const pthread_rwlockattr_t *restrict attr);

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

函数参数

mutex:待初始化 / 销毁的读写锁指针

attr:读写锁属性,NULL表示使用默认属性初始化读写锁

函数返回值

若成功,返回0;否则,返回错误编号

② pthread_rwlock_rdlock / wrlock / unlock / tryrdlock / trywrlock

所需头文件

#include <pthread.h>

函数原型

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

函数参数

mutex:要加锁 / 解锁的读写锁指针

函数返回值

若成功,返回0;否则,返回错误编号

说明:pthread_rwlock_tryrdlock / trywrlock为非阻塞版本,在无法获取读写锁的情况下不会阻塞,而是返回EBUSY

③ pthread_rwlock_timedrdlock / timedwrlock

所需头文件

#include <pthread.h>

#include <time.h>

函数原型

int pthread_mutex_timedrdlock(pthread_rwlock_t *restrict rwlock,

const struct timespect *restrict tsptr);

int pthread_mutex_timedwrlock(pthread_rwlock_t *restrict rwlock,

const struct timespect *restrict tsptr);

函数参数

mutex:要上锁的读写锁指针

tsptr:等待的绝对时间

函数返回值

若成功,返回0;否则,返回错误编号

说明:pthread_rwlock_timedrdlock / timedwrlock为读写锁的带超时版本,使用方法与带超时的互斥锁类似

7.3.4 示例与说明1:读写锁使用

修改之前描述账户信息的结构体,将其中的互斥锁换为读写锁

然后在需要修改Account结构体中的balance字段时申请写锁,在仅读取该字段时申请读锁

7.3.5 示例与说明2:给未上锁的读写锁解锁

根据上文描述,给未上锁的互斥锁解锁不会导致错误,那么读写锁是否有此特性呢 ?

根据实际的运行结果,如果给未上锁的读写锁解锁会导致core dump,相关进程也会被终止

7.4 线程同步:条件变量

7.4.1 条件变量概述

① 解决同步问题

再次强调一下互斥与同步的区别。互斥的线程之间没有依赖关系;而同步的线程之间有运行顺序的约束条件

条件变量通过允许线程阻塞并等待另一个线程发送信号的方法,解决了线程间的同步问题

② 条件变量内部是一个等待队列,用于放置等待的线程,线程在条件变量上等待和通知

互斥锁可以用来保护等待队列(对等待队列上锁),所以条件变量通常和互斥锁一起使用。在条件变量中使用互斥锁时,将互斥锁作为参数传递给相关系统调用即可,系统调用内部会使用该互斥锁保护等待队列

③ 条件变量允许线程等待特定条件发生,当条件不满足时,线程通常先进入阻塞状态,等待条件发生改变。一旦其他线程改变了条件,就可以唤醒一个或多个阻塞的线程

④ 具体的判断条件需要用户定义(理解的关键)

使用条件变量的三要素:条件变量 + 互斥锁 + 判断条件

a. 条件变量

通过条件变量实现线程同步的核心,线程在该条件变量上等待和唤醒

b. 互斥锁

保护判断条件(程序员维护) + 保护等待队列(由pthread_cond_wait函数维护)

c. 用户给出的判断条件

等待线程:修改 / 判断该判断条件,并进入睡眠

唤醒线程:判断该判断条件,决定是否唤醒等待线程

注意1:判断条件的作用经常被忽视,由于要实现线程间的同步,所以对线程的睡眠与唤醒并不是随意的,而是要在指定条件满足时才能进行,否则是无法实现线程间同步的(e.g. 等待线程还没有进入等待,唤醒线程已经调用pthread_cond_signal函数唤醒线程)

注意2:对于判断条件的修改,等待线程和唤醒线程都可以进行,但是都需要用互斥量保护

小结:在使用条件变量实现线程间同步时,

① 条件变量提供机制

② 互斥锁提供保护

③ 判断条件提供语义

7.4.2 条件变量相关函数

① 条件变量的初始化与销毁

所需头文件

#include <pthread.h>

函数原型

int pthread_cond_init(pthread_cond_t *restrict cond,

const pthread_condattr_t *restrict attr);

int pthread_cond_destroy(pthread_cond_t *cond);

函数参数

cond:待初始化 / 销毁的条件变量指针

attr:条件变量属性,NULL表示使用默认属性初始化条件变量

函数返回值

若成功,返回0;否则,返回错误编号

② 等待函数

所需头文件

#include <pthread.h>

函数原型

int pthread_cond_wait(pthread_cond_t *restrict cond,

pthread_mutex_t *restrict mutex);

函数参数

cond:用于等待的条件变量指针

mutex:用于保护判断条件 + 等待队列的互斥锁指针

函数返回值

若成功,返回0;否则,返回错误编号

说明:条件变量的等待函数也有相应的带超时版本,即

int pthread_cond_timedwait(pthread_cond_t *restrict cond,

pthread_mutex_t *restrict mutex,

const struct timespec *restrict tsptr);

此处的等待也是绝对时间,而且要包含<time.h>头文件

③ 唤醒函数

所需头文件

#include <pthread.h>

函数原型

int pthread_cond_signal(pthread_cond_t *cond);

int pthread_cond_broadcast(pthread_cond_t *cond);

函数参数

cond:要唤醒的条件变量指针

函数返回值

若成功,返回0;否则,返回错误编号

说明:唤醒区别

调用pthread_cond_signal函数至少能唤醒一个等待该条件变量的线程

调用pthread_cond_broadcast函数会唤醒所有等待该条件变量的线程

POSIX规范为了简化pthread_cond_signal的实现,允许他在实现时唤醒一个以上的线程

根据Ubuntu 16.04上机验证,当有2个线程等待同一个条件变量时,如果使用pthread_con_signal函数,只唤醒了1个等待线程

7.4.3 示例与说明1:条件变量的使用

首先定义如上图所示的结构体,将同步线程要操作的资源 + 条件变量三元素封装在一起

由于此处对res字段的访问已经同步(即要么在读,要么在写,不会出现同时读写),所以对res字段的访问可以不用互斥量保护(可见后文中代码)

在读写2个线程中,一定是先写后读,所以读线程先调用pthread_cond_wait函数阻塞。读线程在等待之前,先在互斥量的保护下修改了判断条件is_wait的值,表示读线程已做好准备

然后将处于上锁状态的互斥锁作为参数传递给pthread_cond_wait函数,最后在从pthread_cond_wait函数返回后释放互斥锁。这是理解条件变量的难点,现通过如下伪代码说明:

绿框中为pthread_cond_wait函数的内部实现,此处要特别注意红线标出的互斥锁操作配对关系,以此可以理解条件变量的正确使用方式

经过上机验证,在pthread_cond_wait函数返回后,互斥锁确实处于上锁状态

如果在调用pthread_cond_wait函数前后不进行互斥锁的加锁 / 解锁操作,会发生啥情况呢 ?

要点如下:

① 根据之前互斥锁部分的分析,绿框中对未上锁的互斥锁进行解锁操作,不会导致错误

② 在线程进入阻塞时,互斥锁已经解锁,因此写线程可以获取该锁并唤醒读线程

③ 由于蓝框中的加锁操作,从pthread_cond_wait函数中返回时互斥锁处于加锁状态

④ 如果此时在循环中调用pthread_cond_wait函数,将引入如下风险

a. 由于从pthread_cond_wait函数返回时互斥锁处于上锁状态,写线程会无法获取该锁

b. 直到读线程再次修改is_wait的值,并再次执行绿框中的解锁操作,写线程才有可能获取互斥锁

c1. 如果写线程在pthread_cond_wait函数操作等待队列之前获得互斥锁,由于此时is_wait已经为1,但是读线程尚未进入等待队列阻塞;写线程会调用pthread_cond_broadcast唤醒,但此时尚无可唤醒的线程。但是因为写线程是在循环中反复轮询is_wait的状态,所以可以在等待队列操作后再次轮询到is_wait的值并再次唤醒(也就是说会漏唤醒一次,在单次读写中可能没有问题,但是在循坏的读者-写者问题中可能会导致严重的数据不一致错误)

c2. 如果写线程在pthread_cond_wait函数操作等待队列之后获得互斥锁,那么写线程可以正确唤醒读线程

结论:根据上文分析,这种不规范的调用可能造成严重的数据不一致错误,在使用中必须避免

特别说明:上面的分析可能是有问题的,因为规范调用中也可能出现在读线程操作等待队列之前写线程调用pthread_cond_broadcast,但是我们仍然建议按规范使用条件变量(毕竟判断条件还是要互斥保护的)

讨论更新:导致上述复杂讨论的原因其实是我们认为在pthread_cond_wait函数内部,首先会解锁,然后再加锁操作等待队列,最后再解锁并进入睡眠,等待条件改变

在这解锁和加锁之间就存在时隙,可能被唤醒线程占用。但是实际上POSIX要求解锁并阻塞是一个原子操作,这样也就不存在时隙问题,即之前的讨论前提已经失去,命题也就没有意义了~~

作为补充,我们通过实例说明判断条件的重要性,如果没有判断条件,实际无法通过条件变量实现同步

等待线程将互斥锁上锁,然后调用pthread_cond_wait等待

唤醒线程则是在没有任何判断的情况下在cond条件上唤醒线程

经过验证,如果先启动唤醒线程,并在制造时隙后启动等待线程,等待线程将不会被唤醒。(这和信号量的操作不一样,信号量的V操作相当于计数值的累加,而条件变量的唤醒操作则是实时唤醒当前在等待队列中等待的线程 [关于信号量相关的结论已上机验证])

参考资料:

https://blog.csdn.net/dddddz/article/details/8619141

在写线程中,为确保读线程有机会获取互斥锁并修改判断条件,在调用usleep睡眠之前需要解锁。但是在睡眠结束后,需要再次访问is_wait字段之前仍需要互斥锁保护

7.4.4 示例与说明2:读者-写者问题

上面的示例只是一次读写而且是写线程向读线程的单向通知,下面介绍的读者-写者问题则是循环读写,而且读写线程相互通知

在读者-写者问题中,使用的数据结构如下:

读 / 写线程操作的数据为value;由于要做到双向等待 & 通知,所以需要2组条件变量三元素,并分别按读 & 写组织,其中,

① 写者线程

a. 判断读条件(r_wait),并据此唤醒读者线程

b. 在写条件(w_wait)上等待

② 读者线程

a. 在读条件(r_wait)上等待

b. 判断写条件(w_wait),并据此唤醒写者线程

说明1:在实现同步之后,对value的使用已经无需互斥,因为写的时候不会读,读的时候也不会写

说明2:第1次交互时肯定是先写后读,所以读线程先在r_wait上等待,而写线程在修改完value的值后先判断r_wait

读者线程首先修改判断条件r_wait的值,然后在读条件上等待;当线程被唤醒后,读取value值;然后判断写条件,并据此唤醒写者进程

写者线程则是先修改value值,然后判断读条件,并据此唤醒读者线程;然后修改写条件,并在写条件上等待

从运行结果看就是读写交替进行~~

注意:读者-写者问题延伸

① 此处的读者-写者问题是最简单的情况,即一个写者,一个读者

② 再复杂一点就是一个写者,多个读者

此时写者线程在判读读条件时,应该是一个计数器而不是一个布尔值;而读者线程在唤醒写者线程时,也必须等待多个读者都读完

③ 更复杂的情况是多个写者,多个读者

讲真,这个问题我目前还不完全会处理~~

7.5 线程同步:信号量

7.5.1 信号量概述

7.5.1.1 线程信号量的由来

此处介绍的线程信号量实际源于 POSIX信号量,该信号量既可以用于进程间通信(IPC)也可以用于线程间控制。之所以能同时在进程间 & 线程间使用,是因为POSIX信号量分为命名 & 未命名两种,他们的差异在于创建和销毁的形式,但其他工作方式一样

① 未命名信号量:只存在于内存中,并要求能使用信号量的进程必须可以访问相应内存,因此只有2种工作方式,

a. 同一进程中的线程(也就是本节的线程信号量)

b. 不同进程中已经映射信号量所在内存到他们的地址空间中的线程(但是这种情况下不如使用命名信号量了,因此在实际应用中非常罕见)

② 命名信号量:存在于文件系统中,因此可以被任何已知他们名字的进程中的线程使用

7.5.1.2 信号量的使用

信号量本质上是一个非负整数计数器,标识共享资源的数目,通常被用来控制对共享资源的访问

① 用于互斥

当使用信号量实现互斥时,几个进程(或线程)往往只设置一个信号量且信号量初始值为1

② 用于同步

当使用信号量实现同步时,一般会设置多个信号量,并设置不同的初始值来控制线程间的执行顺序

在下面的示意图中,通过2个信号量可以确保线程1先于线程2运行

当然,还可以有其他的信号量同步操作结构,以reader-writer问题为例,

7.5.2 信号量相关函数

① 信号量的初始化与销毁

所需头文件

#include <semaphore.h>

函数原型

int sem_init(sem_t *sem, int pshared, unsigned int value);

int sem_destroy(sem_t *sem);

函数参数

sem:待初始化 / 销毁的信号量指针

pshared:信号量是否在进程间共享,若共享需设置为非0值

value:信号量初始值

函数返回值

若成功,返回0;否则,返回-1

说明:《嵌入式应用程序设计综合教程》中提到Linux中没有实现线程信号量在进程间共享,所以pshared值始终为0

而且在实际使用中,也极少用到未命名信号量在进程间共享,所以pshared值一般均设置为0

② 信号量的申请与释放

所需头文件

#include <semaphore.h>

函数原型

int sem_wait(sem_t *sem);

int sem_post(sem_t *sem);

函数参数

sem:申请 / 释放的信号量指针

函数返回值

若成功,返回0;否则,返回-1

说明1:信号量与PV操作

PV操作为操作系统原语,其中,

P操作:减少信号量

V操作:增加信号量

而且加减操作的步长可设置,因此sem_wait & sem_post函数相当于,

sem_wait:减1操作 --> P(1)

sem_post:加1操作 --> V(1)

说明2:sem_wait阻塞

当线程调用sem_wait时,若信号量的值为0,则线程阻塞;直到成功使信号量减1(有线程调用sem_post释放信号量)或者被信号中断才返回

说明3:信号量申请的非阻塞 & 带超时版本

① int sem_trywait(sem_t *sem);

若成功,返回0;若出错,返回-1,且errno被置为EAGAIN

② int sem_timedwait(sem_t *restrict sem, const struct timespec *restrict tsptr);

若成功,返回0;若出错,返回-1,且errno被置为ETIMEDOUT

此处可见信号量操作与之前介绍的互斥锁 & 条件变量不同,信号量出错时的返回值均为-1,需要通过errno才能获悉出错的原因(其他互斥 / 同步手段是将错误码返回)

③ 信号量值的查询

所需头文件

#include <semaphore.h>

函数原型

int sem_getvalue(sem_t restrict sem, int *restrict valp);

函数参数

sem:要查询的信号量指针

valp:接收查询值的指针

函数返回值

若成功,返回0;否则,返回-1

说明:当试图使用刚读取的信号量值时,信号量值可能已被修改。因此除非使用额外的同步机制来避免这种竞争,sem_getvalue函数只能用于调试

7.5.3 示例与说明1:信号量实现互斥

修改之前描述银行账户的结构体,使用信号量替换互斥锁,然后在初始化函数中将该信号量初始值设为1

相应地,在操作共享资源前后进行sem_wait & sem_post操作

7.5.4 示例与说明2:信号量实现同步

假设有计算和获取结果2个线程,那么一定是计算线程完成后,获取结果的线程才能继续运行

下面使用信号量来处理同步问题,依据惯例我们将共享资源与用于同步的信号量封装在一起,并在初始化时将信号量的初始值设为0

然后获取结果的线程会由于开始处的sem_wait被阻塞,直到计算线程在计算完成并设置共享资源之后调用sem_post将其唤醒。经过上机测试,无论计算线程与获取结果线程的运行顺序,该程序均运行正常

说明1:同步之后还要互斥吗 ?

从宏观上来说,同步手段就是用于协调生产线程与消费线程的工作,确保在有共享资源时才调度消费线程工作,所以他要处理的问题与互斥不同但是相关

如果共享资源只有一个单位(e.g. 示例中的计算结果),那么实现同步之后,实际是无需再处理互斥的,因为生产线程 & 消费线程不会同时访问该资源

但是如果共享资源不止一个单位(e.g. 共享资源为一个不止一个单位的FIFO),那么该共享资源就可能同时被生产线程 & 消费线程访问(e.g. FIFO既非空也非满时),那么此时仍然要处理互斥问题。而此时同步的问题,就是FIFO满时,生产线程不能运行;FIFO空时,消费线程不能运行

所以问题的关键还是准确理解互斥与同步要解决的问题为何~

说明2:信号量与条件变量处理同步问题的比较

① 从使用角度看,信号量实现同步比条件变量简单。条件变量实现同步时,需要条件变量 + 互斥量 + 判断条件三要素;而信号量无需复杂的条件配合。我们可以通过实例体会信号量在使用上的简便之处,我们使用信号量替换条件变量实现之前的reader-writer问题

a. 共享资源结构定义

可见一个信号量即可替换条件变量的三元素,在初始化时将两个信号量的初值均设置为0

b. writer线程

由于在reader-writer问题中,一定是先写后读,所以writer线程先写入数据,然后V操作读信号量,最后等待写信号量

c. reader线程

由于必须先写后读,所以读线程首先P操作读信号量,在写线程进行相应的V操作后,读线程可以读取数据;在读取完成后,释放写信号量,开启写线程的下一次写操作

从代码中被注释掉的大段内容就可以知道,在只用信号量处理同步问题时,比条件变量简单很多

② 条件变量处理同步的特点

根据之前的分析,使用条件变量实现同步时对生产线程的唤醒时间点是有要求的。如果生产线程在消费线程没有进入等待的情况下调用pthread_cond_signal / broadcast函数,后续的等待将无法唤醒。因此必须借助三元素中的判断条件,消费线程查看 / 修改判断条件并进入等待;生产线程查看判断条件,并据此唤醒

③ 信号量处理同步的特点

根据上文分析,由于信号量可以理解为一个计数器,所以在实现同步时无需查询条件,而是可以直接调用sem_post释放信号量;而且一般信号量的初始值与资源单位数也是对应的

④ 根据上文分析,在可能的情况下,我们可以选择使用信号量来处理同步问题,这样可以使得代码结构更加简洁。那么信号量的代价是啥呢,由谁来承担这个代价呢~

互斥锁是为了上锁设计的,条件变量是为了等待设计的,而信号量既可以用于上锁也可以用于等待,因而可能导致更多的开销和更高的实现复杂性

参考资料:

https://www.cnblogs.com/feisky/archive/2010/03/08/1680950.html

7.6 其他

Linux中还提供了自旋锁可用于实现互斥;屏障可用于实现同步。

自旋锁的使用方式和互斥锁类似,但是他不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等阻塞状态。因此一般可以用于锁被持有的时间短,而且线程不希望在重新调度上花费太多成本的场合(但在用户层并不常用)

屏障(barrier)则是一种协调多个线程并行工作的同步机制,屏障允许每个线程等待,直到指定数量的线程都达到某一点,然后各线程均被唤醒,从该点继续执行

7.7 增补:屏障与并行计算

7.7.1 屏障(barrier)概述

① 屏障是用户协调多个线程并行工作的同步机制,屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行

② pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出

7.7.2 屏障相关函数

① 屏障初始化

所需头文件

#include <semaphore.h>

函数原型

int pthread_barrier_init(pthread_barrier_t *restrict barrier,

const pthread_barrierattr_t *restrict attr,

unsigned int count);

函数参数

barrier:待初始化的屏障指针

attr:屏障属性,NULL表示使用默认属性初始化屏障

count:必须到达屏障的数目

函数返回值

若成功,返回0;否则,返回错误编号

说明:在允许所有线程继续运行之前,到达屏障的线程个数必须是count个

② 屏障销毁

所需头文件

#include <semaphore.h>

函数原型

int pthread_barrier_init(pthread_barrier_t  *barrier);

函数参数

barrier:待初销毁的屏障指针

函数返回值

若成功,返回0;否则,返回错误编号

③ 屏障等待

所需头文件

#include <semaphore.h>

函数原型

int pthread_barrier_init(pthread_barrier_t *barrier);

函数参数

barrier:待初始化的屏障指针

attr:屏障属性,NULL表示使用默认属性初始化屏障

count:必须到达屏障的数目

函数返回值

若成功,返回0或者PTHREAD_BARRIER_SERIAL_THREAD;否则,返回错误编号

说明:PTHREAD_BARRIER_SERIAL_THREAD定义如下,特别注意,这个值是-1,所以不能用小于0来判断函数是否执行成功

使得屏障到达指定指数的线程,将返回该值

7.7.3 并行计算示例

假设将求和计算的任务分配到不同子线程运行,然后在主线程中汇合各子线程的计算结果

① 计算结构体

struct cal_part为每个子线程的计算结构体,用于子线程计算[start, end]之和

② 主线程

说明:初始化屏障时,将count设置为THREAD_NUM + 1,是为了让主线程也可以作为一个等待线程

③ 计算子线程

从运行结果分析,子线程使得屏障到达指定指数。但是由于我们指定在主线程汇总计算,所以无需根据pthread_barrier_wait函数的返回值进行操作

8. 线程的状态转换

① 线程创建后即进入就绪态(Runnable)

② 获取CPU后进入运行态(Running),时间片耗尽后回到就绪态

③ 在运行态调用sleep / pthread_join等导致阻塞的函数后,线程进入阻塞态

④ 线程阻塞条件解除后,需要先回到就绪态,才能继续参与调度

⑤ 在运行态调用pthread_exit函数或者由于异常,线程退出

⑥ 在运行态进行上锁操作,在无法获取锁的情况下,线程进入锁定阻塞状态

⑦ 处于锁定阻塞的线程,当要获取的锁被释放时进入就绪态,并由调度程序决定谁最终运行

⑧ 在运行态调用pthread_cond_wait函数,线程进入等待阻塞状态。当其他线程调用pthread_cond_signal / phtread_cond_broadcast唤醒该线程后,由于pthread_cond_wait函数中还要获取互斥锁,因此还有可能进入锁定阻塞状态

注意:上图区分了阻塞、锁定阻塞和等待阻塞,其实在操作系统调度中统一为阻塞态,此处只是为了更细致地区分导致阻塞的不同原因

9. 线程控制

9.1 线程对象属性概述

pthread接口允许程序员通过设置每个线程对象关联的不同属性来调整线程的各种行为,管理这些属性的函数均遵循如下模式,

① 每个线程对象有自己关联的属性对象(e.g. 线程与线程属性关联,互斥锁与互斥锁属性关联)

② 一个属性对象可以代表多个属性,且属性对象对应用程序是不透明的(这样可以增强应用程序的可移植性)

③ 应用程序只能通过操作系统提供的函数来管理这些属性对象

④ 操作系统一般提供如下管理属性对象的函数,

a. 初始化函数,把属性设置为默认值

b. 销毁函数,如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源

c. 每个属性有一个从属性对象中获取其属性值的函数(由于这类函数的返回值用来标识函数调用是否成功,所以属性值需要通过出参指针带出)

d. 每个属性有一个设置属性值到属性对象中的函数(属性值作为参数按值传递)

9.2 线程属性

9.2.1 线程属性类型

在POSIX线程库中使用pthread_attr_t类型表示线程属性类型,在Ubuntu 16.04上将其实现为一个联合体

可见线程属性的具体内容完全向应用程序隐藏,我们只能通过操作系统提供的接口来管理线程属性

根据POSIX标准,属性中包含如下项目,但是需要注意的是,不同操作系统在实现时还可以扩展属性

属性

描述

detachstate

线程的分离属性状态

stackaddr

线程栈的最低地址(地址低端)

stacksize

线程栈的长度(字节数)

guardsize

线程栈末尾的警戒缓冲区大小(字节数)

9.2.2 线程属性相关函数

9.2.2.1 线程属性的初始化与销毁

所需头文件

#include <pthreade.h>

函数原型

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destroy(pthread_attr_t *attr);

函数参数

attr:待初始化 / 销毁的线程属性指针

函数返回值

若成功,返回0;否则,返回错误编号

9.2.2.2 分离属性获取与设置

所需头文件

#include <pthreade.h>

函数原型

int pthread_attr_getdetachstate(cosnt pthread_attr_t *restrict attr, int *detachstate);

函数参数

attr:待操作的线程属性指针

detachstate:存储分离属性的内存指针

函数返回值

若成功,返回0;否则,返回错误编号

所需头文件

#include <pthreade.h>

函数原型

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);

函数参数

attr:待操作的线程属性指针

detachstate:要设置的分离属性值

函数返回值

若成功,返回0;否则,返回错误编号

说明1:detachstate取值

类型

说明

PTHREAD_CREATE_JOINABLE

(默认值)

正常启动线程

PTHREAD_CREATE_DETACHED

以分离状态启动线程

以默认方式启动线程,在线程结束后不会自动释放占用的系统资源,需要在主控线程中调用pthread_join后才会释放;以分离状态启动线程,在线程结束后会自动释放所占用的系统资源

说明2:分离属性在网络通讯中使用较多

9.2.2.3 线程栈属性获取与设置

所需头文件

#include <pthreade.h>

函数原型

int pthread_attr_getstackaddr(const pthread_attr_t *restrict attr, void **restrict stackaddr);

int pthread_attr_getstacksize(const pthread_attr_t *restrict attr, size_t *restrict stacksize);

函数参数

attr:待操作的线程属性指针

stackaddr:存储线程栈最低地址的内存指针

stacksize:存储线程栈长度的内存指针

函数返回值

若成功,返回0;否则,返回错误编号

所需头文件

#include <pthreade.h>

函数原型

int pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr);

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

函数参数

attr:待操作的线程属性指针

stackaddr:要设置的线程栈最小地址

stacksize:要设置的线程栈长度

函数返回值

若成功,返回0;否则,返回错误编号

说明1:适时调整线程栈(其实不常发生,但是要有这个意识)

① 由于进程的虚拟地址空间是固定的,如果应用程序开启了许多线程,以至于这些线程栈的累计大小超过了可用的虚拟地址空间,就需要减少默认的线程栈大小

② 如果线程调用的函数分配了大量的局部变量,或者调用的函数涉及许多很深的栈帧(e.g. 递归调用),就需要增加默认的线程大小

说明2:可以在堆上分配线程栈

如果线程栈的虚拟地址空间耗尽,可以使用malloc或者mmap来为可替代的栈分配空间,然后调用pthread_attr_setstackaddr & pthread_attr_setstacksize来改变新建线程的栈的位置

9.2.3 示例与说明1:分离属性

说明1:对分离状态的线程无需也无法调用pthread_join回收

以分离状态启动线程时,调用ptheread_join函数回收其状态会返回错误,因此也无法通pthread_join的第二个参数获得子线程的返回值

说明2:调用pthread_detach函数实现线程分离

本节示例中是通过设置线程分离属性,然后以该属性启动线程。但是也可以通过调用pthread_detach函数,将以非分离属性启动的线程设置为分离属性,该函数原型如下,

所需头文件

#include <pthreade.h>

函数原型

int pthread_detach(pthread_t tid);

函数参数

tid:要设置分离属性的线程ID

函数返回值

若成功,返回0;否则,返回错误编号

该函数有2种调用方式:

① 启动函数调用

经过上机验证,只要在线程启动后主控线程调用pthread_detach函数设置该线程的分离属性,就无法调用pthread_join函数回收其状态(原先有个担心,即线程运行完成才调用pthread_detach函数会失效)

② 线程自己调用

线程可以通过调用pthread_detach(pthread_self())设置自身的分离属性,但是此处的延时就不可避免了。经过上机验证,如果新线程尚未设置而主控线程已经进入pthread_join等待,则仍然可以回收到线程状态

需要说明的是,调用pthread_detach函数设置线程分离属性并不是个好办法,此处仅为知识的完备性加以介绍

9.2.4 示例与说明2:线程栈属性

说明1:线程栈的默认属性

根据运行结果,线程栈的最低地址为0,应该体现为一个偏移量;线程栈长度为8MB

说明2:pthread_attr_getstack函数失效

根据《UNIX环境高级编程》,可以通过pthread_attr_getstack函数获取线程栈的属性,但是在Ubuntu 16.04中获取的线程栈最低地址和长度均为0

9.3 互斥锁属性

9.3.1 互斥锁属性类型

在POSIX线程库中使用pthread_mutex_t类型表示线程属性类型,在Ubuntu 16.04上将其实现为一个联合体

可见其中的细节也是隐藏的,因此也只能通过操作系统提供的接口进行访问与设置

9.3.2 互斥锁属性相关函数

① 互斥锁属性的初始化与销毁

所需头文件

#include <pthreade.h>

函数原型

int pthread_mutexattr_init(pthread_mutexattr_t *attr);

int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);

函数参数

attr:待初始化 / 销毁的互斥锁属性指针

函数返回值

若成功,返回0;否则,返回错误编号

② 共享属性获取与设置

所需头文件

#include <pthreade.h>

函数原型

int pthread_mutexattr_getshared(cosnt pthread_mutexattr_t *restrict attr,

int *pshared);

函数参数

attr:待操作的互斥锁属性指针

pshared:存储共享属性的内存指针

函数返回值

若成功,返回0;否则,返回错误编号

所需头文件

#include <pthreade.h>

函数原型

int pthread_mutexattr_setshared(pthread_mutexattr_t *attr, int shared);

函数参数

attr:待操作的互斥锁属性指针

shared:要设置的共享属性值

函数返回值

若成功,返回0;否则,返回错误编号

说明1:shared取值

类型

数目

PTHREAD_PROCESS_PRIVATE

(默认值)

互斥锁只能用于一个进程内部的线程进行互斥

PTHREAD_PROCESS_SHARED

互斥锁可以用于不同进程的线程进行互斥

说明1:对进程间线程共享互斥锁的理解

在Linux IPC中存在共享内存机制,即允许相互独立的多个进程把同一个内存数据块映射到他们各自独立的地址空间中。如果在进程间的共享内存中定义互斥锁,然后在初始化时指定PTHREAD_PROCESS_SHARED属性,那么不同进程间的线程就可以使用该互斥锁

其实这里就可以探讨下进程间互斥与线程间互斥手段的一般性差别,由于线程共享进程资源,所以线程间互斥机制只要存在于所有线程均可访问的内存中即可,所以一般都采用无名机制

但是进程间的虚拟地址空间是完全隔离的,所以可以借助文件系统实现互斥机制,只要互斥机制存在于文件系统中(因此一般采用有名机制),那么知道该互斥机制名字的进程则均可使用

上述两种使用方式是最契合使用情景的,而将存在于内存的互斥机制映射到不同进程使用其实并不常见

③ 类型属性的获取与设置

所需头文件

#include <pthreade.h>

函数原型

int pthread_mutexattr_getshared(cosnt pthread_mutexattr_t *restrict attr,

int *ptype);

函数参数

attr:待操作的互斥锁属性指针

ptype:存储类型属性的内存指针

函数返回值

若成功,返回0;否则,返回错误编号

所需头文件

#include <pthreade.h>

函数原型

int pthread_mutexattr_setshared(pthread_mutexattr_t *attr, int type);

函数参数

attr:待操作的互斥锁属性指针

type:要设置的类型属性值

函数返回值

若成功,返回0;否则,返回错误编号

说明:type取值

类型

说明

PTHREAD_MUTEX_NORMAL

标准互斥锁类型,不做任何特殊的错误检查或死锁检查

行为:第1次上锁成功,第2次上锁阻塞

PTHREAD_MUTEX_ERRORCHECK

该互斥锁类型提供错误检查

行为:第1次上锁成功,第2次上锁出错返回

PTHREAD_MUTEX_RECURSIVE

该互斥锁类型允许同一线程在互斥锁解锁之前对该互斥锁进行多次加锁。递归互斥锁内部维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁

行为:第1次上锁成功,第2次及以后上锁仍成功,内部计数

PTHREAD_MUTEX_DEFAULT

(默认值)

该互斥锁类型提供默认特性和行为,操作系统在实现时可以将该类型映射到上述互斥锁类型的一种。Linux 3.2将该类型映射到normal类型;FreeBSD 8.0将该类型映射到errorcheck类型

9.3.3 示例与说明:互斥锁类型属性

根据上机验证,实际结果与之前分析一致

补充:读写锁 & 条件变量 & 屏障属性

读写锁、条件变量和屏障的属性此处就不再赘述了,一来与之前介绍的线程对象属性套路一致;二来他们的主要属性就是共享属性,且行为与之前介绍的互斥锁共享属性一致

Linux应用编程基础04:Linux线程编程相关推荐

  1. 【嵌入式Linux】嵌入式Linux应用开发基础知识之多线程编程

    文章目录 前言 1.多线程基础编程--创建线程和使用等待函数休眠线程 1.1.程序分析--使用信号量PV操作sem_wait 1.2.程序分析--使用条件变量pthread_cond_wait 2.一 ...

  2. linux需要什么基础,学linux需要什么基础?

    近几年来,随着计算机网络的发展,越来越多的人学习 linux.对于想要从事运维工作或者从事智能开发方面的同学来说,学习 linux 是必要的.linux 的学习并不简单,那么这篇文章 w3cschoo ...

  3. 迈入JavaWeb第一步,Java网络编程基础,TCP网络编程URL网络编程等

    文章目录 网络编程概述 网络通信要素 要素一IP和端口号 要素二网络协议 TCP网络编程 UDP网络编程 URL网络编程 Java网络编程基础 网络编程概述 Java是Internet上的语言,它从语 ...

  4. 编程基础 垃圾回收_编程中的垃圾回收指南

    编程基础 垃圾回收 什么是垃圾回收? (What is Garbage Collection?) In general layman's terms, Garbage collection (GC) ...

  5. Python编程基础21:GUI编程

    文章目录 零.本讲学习目标 一.图形用户界面 - GUI (一)GUI概述 (二)常用的Python GUI库 1.Tkinter库 2.wxPython库 3.Jython库 二.tkinter编程 ...

  6. c语言编程基础心得,C语言编程学习心得体会

    C语言是在国内外广泛使用的一种计算机语言.其语言功能丰富.表达能力强.使用灵活方便.既具有高级语言的优点,又具有低级语言的许多特点,适合编写系统软件.本文是C语言编程学习心得,希望对大家有帮助. C语 ...

  7. 南开大学python编程基础_《Python编程基础》20春期末考核(参考答案)南开大学 答案...

    <Python编程基础>20春期末考核 -00001 试卷总分:100  得分:70 一.单选题 (共 15 道试题,共 30 分) 1.执行"print(0o20)" ...

  8. c语言编程基础 教案,C语言编程基础电子教案.doc

    C语言编程基础电子教案 课题(内容)1.1 C语言简史及特点课时1教学任务分析教学目标知识技能通过本节课的教学,使学生了解并熟悉编程语言C的发展历史.特点及其种类和适用范围.过程与方法通过C语言的发展 ...

  9. spark编程基础python版 pdf_Spark编程基础Python版-第5章-Spark-SQL.pdf

    <Spark编程基础(Python版)> 教材官网:/post/spark-python/ 温馨提示:编辑幻灯片母版,可以修改每页PPT的厦大校徽和底部文字 第5章Spark SQL (P ...

最新文章

  1. 阿里秋招面试全解析(含内推岗)
  2. 最好用浏览器_魔镜魔镜,请你告诉我谁是Mac上最好用的浏览器?--全网最好用的12个功能让你玩转Safari...
  3. 使用spring mail发送html邮件
  4. JAVA POI 应用系列(2)--读取Excel
  5. c++ 虚函数的实现机制
  6. IDEA快捷键显示重载
  7. js 时间获取格式化 fmt
  8. LaTeX(2)——LaTeX文档基本结构
  9. 【转载】 扫描二维码自动识别手机APP下载地址
  10. 阶段2 JavaWeb+黑马旅游网_15-Maven基础_第3节 maven标准目录结构和常用命令_06maven标准目录结构...
  11. Android性能优化系列:内存优化
  12. 计算机网络与Netty - F2F
  13. authentication method 10 not supported
  14. 图像和像素(Images and Pixels)
  15. 三维地图代码 echarts demo
  16. 50个直击灵魂的问题_直击心灵的48个问题
  17. Pinbox 网络收藏夹使用指南
  18. python-介绍泊松分布(poisson分布)
  19. Python学习 Day31 JS类数组对象
  20. GALAXY OJ NOIP2019联合测试2-普及组

热门文章

  1. android expandablelistview横向,Android ExpandableListView使用小结(二)
  2. java 1.6 最大化_关于java:JDK 1.6和1.7中的新功能
  3. 学生成绩abcde怎样划分_7月学考成绩出来啦!
  4. java中流关闭如何打开_关于java中流关闭的问题
  5. Windows环境下Docker常用命令
  6. matlab求傅里叶级数展开式_明明学过积分和三角函数就能秒理解傅里叶变换.........
  7. 定时执行sql统计数据库连接数并记录到表中
  8. 一个action类中写多个方法需要继承MappingDispatchAction
  9. 读写文件--with open
  10. Oracle回收站解决误删除表