Linux系统编程-线程
线程概念
虽然可以使用诸多进程来相互协作实现需要并发才能完成的功能,但进程间的协作有着重要的限制:每个进程有自己的独立空间。这种限制导致进程之间的协作存在明显缺陷:如果相互协作的进程需要动态共享大量的数据,则操作起来十分麻烦。就像一群人之间的协作一样,由于每个人都是独立的个体,各有不同的喜好和习惯,协作难免产生误解和沟通困难。如果一件事情让一个人来处理,协作上就不会发生问题。因此,如果能够让一个进程内部实现并发来完成一个复杂的任务,协作上的难度就会少很多。
在进程内部实现并发就是进程出现的动机,如下图所示:
Linux 下,线程又称为LWP: light weight process
,轻量级进程。
进程 | 线程 |
---|---|
有独立的进程地址空间 | 没有独立的地址空间,多个线程共享 |
有独立的 PCB | 有独立的 PCB (但PCB中指向内存资源的三级页表相同) |
分配资源的最小单位 | CPU 执行的最小单位 |
查看 ps aux / ps ajx |
查看线程号 ps -Lf 进程id |
传统意义上的 UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程。
进程中创建线程后,原进程也降为线程了!进程相当于独居,创建线程后就变成合租(共享地址空间)。
线程是 CPU 执行的最小单位,以下图为例,A分配的CPU时间为3/5
,而 B, C 各只有 1/5
。但也不是说线程越多越好,如下图右为的曲线。
线程共享资源
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户 ID 和组 ID
- 内存地址空间 (text/data/bss/heap/共享库 全局变量),不包括栈。
线程非共享资源
- 线程 ID
- 处理器现场和栈指针(内核栈)
- 独立的栈空间(用户空间栈)
- errno 变量
- 信号屏蔽字
- 调度优先级
线程的优缺点
优点:
- 提高程序并发性
- 开销小
- 数据通信、共享数据方便。只需将数据复制到共享(全局或堆)变量中即可。
缺点:
- 库函数,不稳定
- 调试、编写困难、gdb 不支持
- 对信号支持不好。
优点相对突出,缺点均不是硬伤。Linux 下由于实现方法导致进程、线程差别不是很大。
常用 API
线程相关的函数的man page
可能需要额外下载,sudo apt install manpages-posix manpages-posix-dev
,也可通过man -k pthread
查看相关的函数。
除了上面下载
manpage
,也可以在线查看manpage
Pthread
相关的源码也可在线查看
pthread_self
获取线程 id,类似与进程中的 getpid()
!
线程 id 是在进程地址空间内部,用来标识线程身份的 id。通过 ps -LF 进程id
得到的是线程号LWP
,是操作系统用来区分以分配CPU资源标识,与进程ID的功能类似。
1 |
|
- 返回值:线程 id
pthread_create
创建线程,编译和链接时加 -lpthread
!
1 |
|
- thread:线程 id,传出参数
- attr: 线程属性,默认为 NULL
- start_routine:线程入口函数,函数原型要与
void *(*start_routine) (void *arg)
一致 - arg:上一个参数“传入线程入口函数”的参数
- 返回值:0 成功,非 0 失败,返回的是 errno
循环创建多个线程:
1 |
|
注意上面的代码中,线程入口函数的参数是一个整型变量,而不是指针!
通过值传递,避免与主线程中的变量冲突。若传入的参数是一个指针,因为线程创建需要一定的时间,而这段时间内,主线程可能会改变这个指针的值。导致结果与预期不一致。
检查线程返回错误号,不能使用 perror()
,只能用 strerror()
!
1 | fprintf(stderr, "pthread_create error: %s\n", strerror(ret)); |
pthread_exit
在线程函数函数内部调用,直接结束当前线程,可设置线程退出值。结束之后仍然需要 pthread_join
回收线程资源。
1 |
|
- retval:退出值,无则设 NULL
1 | void *thread_func(void *arg) |
pthread_join
阻塞回收线程,类似于进程中的 waitpid()
!注意,回收线程不一定是由父线程完成,兄弟线程之间可互相回收!
1 |
|
- retval:线程的返回值,传出参数
- 返回值:0 成功,非 0 失败,返回的是 errno
pthread_cancel
杀死线程,类似于进程中的 kill()
!
被杀死的线程会调用 pthread_exit()
,并且会返回 PTHREAD_CANCELED
!
1 |
|
- thread:要杀死的线程 id
- 返回值:0 成功,非 0 失败,返回的是 errno
1 | void *thread_func(void *arg) |
在上面的线程函数中,由于没有进入系统调用,无法用 pthread_cancel()
来杀死线程!
pthread_cancel
只有线程进入系统调用后,才能被杀死!如果子线程逻辑上没有调用系统调用,可以在程序中手动添加取消点 pthread_testcancel()
。
pthread_detach
设置线程分离,这样线程结束时,线程资源 PCB 会被自动释放,而不需要等待主线程回收!
1 |
|
- thread:要分离的线程 id
- 返回值:0 成功,非 0 失败,返回的是 errno
分离后,再次调用 pthread_join()
时,会报错 Invalid argument
!
pthread_cleanup_push/pop
pthread_cleanup_push
和 pthread_cleanup_pop
是 C 语言 POSIX 线程库 (pthreads) 中的函数,它们用于在线程退出时自动执行清理动作。
pthread_cleanup_push
用于注册清理函数,该函数将在线程退出时自动调用。该函数的第一个参数是清理函数的地址,第二个参数是一个指针,该指针用于传递给清理函数的参数。pthread_cleanup_pop
用于弹出最近注册的清理函数。如果第二个参数为非零值,则立即调用清理函数。如果第二个参数为零,则在线程退出时自动调用清理函数。
只有在push
和pop
这两个函数中间线程退出时,才会执行对应的清理函数。退出的原因有:
- 其他线程使用
pthread_cancel()
杀死了此线程。 - 线程自己调用了
pthread_exit()
。
1 |
|
routine
:清理函数指针;arg
:清理函数的传入参数。execute
:第二个参数是一个整数,它控制清理函数是在线程退出时自动调用,还是在该函数被调用时立即调用。- 如果第二个参数为 0,则在线程退出时自动调用清理函数。
- 如果第二个参数非 0,则在该函数被调用时立即调用清理函数。
进程线程对比
进程 | 线程 |
---|---|
fork() |
pthread_create() |
getpid() |
pthread_self() |
exit() |
pthread_exit() |
wait()/waitpid() |
pthread_join() |
kill() |
pthread_cancel() |
pthread_detach() |
线程属性
在线程创建时,就可以设置线程的属性,主要有:
1 | struct pthread_attr { |
一般不直接对线程属性实例进行修改,而是通过提供的函数来设置!下面介绍的函数都是对线程属性实例进行了修改,也就是说,执行执行函数后也还没有任何一个线程受到这些属性的影响。只有用该实例去创建新线程时才生效。
如果想修改当前运行中的线程的属性,往往有对应的不带attr
的函数。
man pthread_attr_init
中有获取线程属性并打印输出的例子,可以查看线程的默认属性。
pthread_attr_init/destroy
对线程属性实例初始化和销毁的函数。
1 |
|
- attr:线程属性结构体指针
- 返回值:0 成功,非 0 失败,返回的是 errno
init
与 destroy
函数要配套使用,类似于 malloc()
与 free()
!
可以看到上面pthread_attr
的结构体中有指针成员,就会涉及到动态内存分配malloc
和内存释放free
,因此每次用完attr
后需要调用destroy
释放内存,避免内存泄露。
pthread_attr_setdetachstate/get
设置线程分离,这样线程结束时,线程资源 PCB 会被自动释放,而不需要等待主线程回收!
1 |
|
attr
:线程属性结构体指针detachstate
:线程分离状态,可以是以下值:PTHREAD_CREATE_JOINABLE
:线程分离状态为非分离(默认选项)PTHREAD_CREATE_DETACHED
:线程分离状态为分离
- 返回值:0 成功,非 0 失败,返回的是
errno
1 | void *thread_func(void *arg) |
pthread_attr_setschedpolicy/get
设置线程调度的策略,支持的有:SCHED_FIFO
, SCHED_RR
, SCHED_OTHER
,关于这几种策略的描述见:man7 sched
1 |
|
返回值:成功返回,失败返回错误号。
SCHED_FIFO
:设置了该策略的线程会一直运行,直到它被IO阻塞或被更高优先级的线程抢占,或者它调用sched_yield
。SCHED_RR
:基于SCHED_FIFO
,但设置了最大执行时间quantum
,执行这么长时间后就会中止,并放入该优先级的调度队列末尾。SCHED_OTHER
:Linux
的默认策略,是一种相对公平的调度策略,类似与时间片轮转,但高优先级分配的时间会更多。
pthread_attr_setschedparam/get
SCHED_FIFO
是基于优先级抢占的,该函数用于设置线程进行调度时的优先级。
1 |
|
pthread_attr_setinheritsched/get
设置线程的继承属性,其实只有调度属性可以继承。
1 |
|
inheritsched
只有两种取值:
PTHREAD_INHERIT_SCHED
:新线程将继承调用pthread_create
的线程的调度策略。PTHREAD_EXPLICIT_SCHED
:新线程的调度策略以线程属性中指定的为准。
也就是说,在使用pthread_attr_setschedpolicy
时,必须也要设置PTHREAD_EXPLICIT_SCHED
,否则调度策略不会生效。
pthread_attr_setscope/get
线程作用域属性描述特定线程将与哪些线程竞争资源。
1 |
|
线程可以在两种竞争域内竞争资源,也是scope
的两种取值:
PTHREAD_SCOPE_SYSTEM
:系统域,与系统中的所有线程。一个具有系统域的线程将与整个系统中所有具有系统域的线程按照优先级竞争处理器资源,进行调度。PTHREAD_SCOPE_PROCESS
:进程域,与同一进程内的其他线程竞争。
pthread_attr_setguardsize/get
设置线程栈保护区的大小,默认保护大小与系统页面大小相同。
在线程栈的末尾分配之一至少guardsize
字节的区域作为堆栈保护区,如果一个线程溢出它的堆栈到保护区,在大多数硬架构上,会产生SIGSEGV
信号,从而通知它溢出。
1 |
|
pthread_attr_setstackaddr/get
当进程栈地址空间不够用时,指定新建线程使用由malloc
分配的空间作为自己的栈空间。
pthread_attr_setstacksize/get
设置线程栈的大小,默认线程栈的大小为8M
。
1 |
|
当进程中有很多线程时,可能需要减小每个线程栈的默认大小,防止进程的地址空间不够用。
当线程调用的函数会分配很大的局部变量或者函数调用层次很深时,可能需要增大线程栈的默认大小。
pthread_attr_setaffinity_np/get
设置线程的CPU亲和性,让线程在指定的某一个核或一组核上运行。
1 |
|
cpusetsize
:应该指定cpuset
参数的字节数,通常设定为sizeof(cpu_set_t)
。cpuset
:核的掩码。
虽然 cpu_set_t
数据类型实现为一个位掩码,但应该将其看成是一个不透明的结构。
所有对这个结构的操作都应该使用宏来完成,下面是部分常用的:
1 | /* man CPU_SET */ |
注意上面宏参数cpu
编号是从0开始。
pthread_getattr_np
获取当前线程的属性,写入到attr
中。
1 |
|
运行时调整线程属性
除了上面在线程创建时设置属性,部分属性也支持运行时进行调整。
静态设置 | 运行时 |
---|---|
pthread_attr_setschedparam |
pthread_setschedparam |
pthread_attr_setaffinity_np |
pthread_setaffinity_np |
CPU亲和性
设置进程在某一个核或一组核上运行,在某些情况下可以提升性能。如果该进程有多个线程,它们都只能在指定的一组核上面运行。也可单独为某一个线程设置亲和性。
1 |
|
pid
:要设置的进程号,也可简单的用0
来表示调用进程,也可用gettid()
传入线程号cpusetsize
:应该指定mask
参数的字节数,通常设定为sizeof(cpu_set_t)
mask
:核的掩码。- 返回值:成功返回0,失败返回
-1
,并设置errno
- 如果
mask
中指定的 CPU 与系统中的所有 CPU 都不匹配,返回EINVAL
错误
- 如果
taskset -p PID
可查看当前进程的mask
,可通过 taskset -pc $pid
来获取某线程与CPU核心的亲和性。
线程注意事项
- 主线程退出其他线程不退出,主线程应调用 pthread_exit
- 避免僵尸线程:
- pthread_join
- pthread_detach
- pthread_create 指定分离属性
- 被 join 线程可能在 join 函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值;
- malloc 和 mmap 申请的内存可以被其他线程释放
- 应避免在多线程模型中调用 fork 除非,马上 exec,子进程中只有调用 fork 的线程存在,其他线程在子进程中均 pthread_exit
- 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制 (多线程中,信号由哪个线程处理不确定!每个线程各有信号屏蔽字mask,共享未决信号集,如果想指定某个线程处理特定信号,可通过设置其他线程的信号屏蔽字)
一次性初始化
Linux-Unix系统编程手册——31.2节
多线程程序有时有这样的需求:不管创建了多少线程,有些初始化动作只能发生一次。如果由主线程来创建新线程,那么这一点易如反掌,可以在创建依赖于该初始化的线程之前进行初始化。不过,对于库函数而言,这样处理就不可行,因为调用者在初次调用库函数之前可能已经创建了这些线程。故而需要这样的库函数:无论首次为任何线程所调用,都会执行初始化动作。
pthread_once 函数
保证无论多少线程、无论调用多少次pthread_once
,都只会执行一次init_routine
初始化函数。
1 |
|
once_control
:必须是一指针,指向初始化为PTHREAD_ONCE_INIT
的静态变量。init_routine
:需要执行的函数,该函数没有任何参数。- 成功返回
0
。