线程
一个进程可以包含多个线程。同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段(initialized data)、未初始化数据段(uninitialized data),以及堆内存段(heap segment)
多线程的进程内存布局
文本段、数据段这些,线程共享,然后会为每个线程分配特定的栈,其中每个进程都有一个主线程,也就是main函数的执行线程
线程的数据类型
这些类型用于编写多线程程序,编译时需要加上 -lpthread
创建线程
进程启动时,会有一条初始线程,即主线程,所以创建线程指代的是创建除主线程之外的线程
需要指定该线程执行的函数
终止线程
线程的终止,如果没有 显式 的return或者exit,那么执行完代码后会正常退出
pthread_exit()
函数相当于return语句,可以退出线程的执行函数,即结束线程,不能直接使用exit函数,这会导致所有线程都终止
线程的连接
线程的连接,其实就是一个线程等待另一个线程执行完毕
若线程并未分离(detached),则必须使用 ptherad_join()来进行接。如果未能连接,那么线程终止时将产生僵尸线程,与僵尸进程(zombie process)的概念相类似。除了浪费系统资源以外,僵尸线程若累积过多,应用将再也无法创建新的线程。
-
线程之间的关系是平等的,它不像进程的wait那样,只能由父进程等待子进程
-
线程只能连接特定线程ID,即它只能连接它所知道的线程,这是为了避免,创建的线程连接库函数私自创建的线程等等带来的问题
线程的分离
线程的分离,默认情况下,线程是可连接的,当设置为分离状态后,该线程退出时,其他线程 无法使用pthread_join()来获取它的返回状态,系统会在其终止时自动清理并移除
需要注意的是任意一个线程exit或者主线程return,分离的线程也会立即终止,分离只是控制线程终止之后所发生的事情,而非何时或如何终止线程
补充
1
线程可以自行分离,但不能自行连接
即pthread_detach(pthread_self())
是合法的,pthread_join(pthread_self(), NULL)
是非法的
当线程调用 pthread_join(pthread_self(), NULL) 时,实际上是在等待它自己终止,而这不可能完成,因为 当前线程无法在等待自身的同时继续运行,也就是说,调用者线程会被 阻塞,导致死锁。
2
以上代码存在以下问题:
-
变量作用域和生命周期
struct someStruct buf;
在 main 函数中定义的 buf 是一个局部变量,具有 main 函数的作用域。当 main 函数执行完毕时,buf 的生命周期结束,buf 的内存将不再有效
如果 threadFunc 线程在 main 函数结束之前还未执行完毕,可能会导致对已释放内存的访问,造成未定义行为
-
线程未正确处理
pthread_create(&thr, NULL, threadFunc, (void *) &buf);
代码中调用 pthread_create 创建线程,但是缺少 pthread_join 或适当的退出处理,主线程直接调用了 pthread_exit(NULL),这可能会导致主线程退出,threadFunc 线程可能未能正确运行完毕
3
在Linux中,当主线程(即main
线程)退出后,如果其他线程仍在执行,整个进程的行为取决于主线程的退出方式和其他线程的状态。以下是几种情况的详细说明:
-
主线程先于其他线程退出
-
主线程正常退出
- 使用
return
或exit()
:- 当主线程正常退出时,进程会被终止,所有的子线程(包括正在运行的其他线程)都会被强制终止
- 这种情况下,线程的清理工作不会被执行,可能导致未释放的资源(如内存或文件描述符)
- 使用
-
主线程调用
pthread_exit()
- 如果主线程调用
pthread_exit()
,则该线程会退出,但进程不会立即终止。其他线程仍然会继续执行 - 在这种情况下,进程会保持运行状态,直到所有线程都退出或者调用
pthread_cancel()
等其他方式强制终止
- 如果主线程调用
-
主线程被其他线程取消
- 如果主线程被其他线程调用
pthread_cancel()
,主线程会被终止。与正常退出相似,所有未完成的子线程也会被强制终止
- 如果主线程被其他线程调用
-
-
线程的状态和清理
- 如果主线程退出,其他线程会根据其状态决定是否继续执行。若主线程以
pthread_exit()
退出,其他线程将继续执行,但进程最终仍会在所有线程完成后退出 - 需要注意的是,其他线程可能会遭遇资源泄露,因为主线程的退出可能导致无法正确释放资源
- 如果主线程退出,其他线程会根据其状态决定是否继续执行。若主线程以
-
使用
pthread_join()
- 为了避免资源泄露,通常在主线程中调用
pthread_join()
等待其他线程完成。这样,主线程会在其他线程退出后再返回,确保所有线程正常结束和资源释放
- 为了避免资源泄露,通常在主线程中调用
线程同步
临界区
临界区就是指访问共享资源的代码段,以下是几个关键概念:
-
共享资源
共享资源可以是任何被多个线程访问的数据或状态,例如全局变量、静态变量、动态分配的内存等
-
互斥访问
为了保护临界区,必须确保 对临界区的访问是互斥的,即在任一时刻只能有一个线程可以进入临界区
互斥的实现通常使用锁机制,例如互斥锁(mutex)或读写锁
-
临界区的必要性
当多个线程同时读写同一共享资源时,如果不加以控制,就会导致数据竞争(Race Condition),使得程序的行为不可预测,甚至可能崩溃
互斥量
互斥量可以帮助线程同步对共享资源的使用,以防如下情况发生:线程某甲试图访问一共享变量时,线程某乙正在对其进行修改。
互斥量的初始化
- 静态分配
// 全局变量
static pthread_mutex_t mutex = PTHREAT_MUTEX_INITIALIZER;
静态初始化的互斥量,存储在全局区,无需显示的销毁互斥量,程序结束后会自动回收,简单方便
- 动态初始化
对于以上的三种情况要使用动态初始化,即使用以下函数初始化,并且要显示的销毁
互斥量的销毁
只有动态初始化的互斥量需要使用该函数进行显式销毁,静态分配的互斥量会自动回收
互斥量的行为(加锁、解锁)
每个线程访问同一资源的时候,都应该遵循以下步骤:
- 针对共享资源锁定互斥量
// 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 访问共享资源
/* 访问共享资源的代码 */
- 对互斥量解锁
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
对互斥量的加锁解锁也会带来一定的开销,所以不要多次的加锁解锁,若是一次加锁解锁就能满足线程的访问需求更好,比如说如果是循环方式的话,可以考虑把加锁解锁放到循环之外
需要注意某些对于互斥量的非法行为:
流程图如下,互斥量同一时间只能由一个线程持有,其他线程试图持有会被阻塞
互斥量死锁
其中每个线程都成功地锁住一个互斥量,接着试图对已为另一线程锁定的互斥量加锁。两个线程将无限期地等待下去
条件变量
条件变量允许线程相互通知共享变量(或其他共享资源)的状态发生了变化
他的作用是,当一个线程不断的循环检测某一个共享资源的状态,这会造成cpu的资源浪费,可以使用条件变量,允许一个线程休眠,直到接收到另一线程的通知才取执行某些操作
条件变量的初始化
- 静态分配
// 全局变量
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
无需显式销毁
-
动态初始化
条件和互斥量动态初始化类似,需要显式销毁
条件变量的销毁
对某个条件变量而言,仅当没有任何线程在等待它时,将其销毁才是安全的
条件变量的行为(通知、等待)
- 通知
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒所有等待的线程
- 等待
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); // 阻塞线程(调用者)直到接到通知
条件变量与互斥变量的联系
函数pthread_cond_wait()会自动执行最后两步中对互斥量的解锁和加锁动作,即这里的3和4属于原子操作
换句话说:
- 当线程陷入休眠时,pthread_cond_wait会自动解锁持有的互斥量
- 当线程被唤醒时,pthread_cond_wait会自动加锁需要的互斥量
测试条件变量的判断条件
每个条件变量都有与之相关的判断条件,涉及一个或多个共享变量
从pthread_cond_wait返回时,即线程被唤醒,并不能确定判断条件的状态,需要立即重新检查,原因如下:
- 其他进程率先醒来,修改了共享变量,和预期不符合
- 虚假唤醒
所以要采用循环检测的方法,而不是用if,如下:
// 不要用if
if (var == 0)
pthread_cond_wait(&cond, &mtx);
// 使用while
while (var == 0)
pthread_cond_wait(&cond, &mtx);
线程安全
若函数可同时供多个线程安全调用,则称之为线程安全函数;反之,如果函数不是线程安全的,则不能并发调用。
实现线程安全的方式
-
将函数与互斥量关联使用
在调用函数时将其锁定,在函数返回时解锁,这意味着一次只能有一个线程执行该函数,即 串行执行,虽然简单,但导致了多线程并行能力的损失
-
将共享变量与互斥量关联起来
在执行到临界区时,才获取或释放互斥量,较为复杂,但保留了线程的并行能力
-
可重入
可重入函数则无需使用互斥量即可实现线程安全。其要诀在于避免对全局和静态变量的使用。需要返回给调用者的任何信息,亦或是需要在对函数的历次调用间加以维护的信息,都存储于由调用者分配的缓冲区内。
一次性初始化
一次性初始化就是,只会初始化一次,无论调用几次,由以下函数实现:
利用参数 once_control 的状态,函数 pthread_once()可以确保无论有多少线程对pthread_once()调用了多少次,也只会执行一次由init指向的调用者定义函数。
// 全局变量
static pthread_once_t once_control = PTHREAD_ONCE_INIT;
- once_control 是一个控制变量,用于跟踪初始化是否已经完成
- 初始化函数 init: 这个函数负责执行只需一次的初始化逻辑,例如设置共享资源的值
每线程存储
每线程存储,就是每个线程到单独存储要访问的数据,避免了资源竞争,也就不需要用互斥量了,实现了可重入,也就是线程安全
线程特有数据
线程特有数据使函数得以为每个调用线程分别维护一份变量的副本(copy)。线程特有数据是长期存在的。在同一线程对相同函数的历次调用间,每个线程的变量会持续存在,函数可以向每个调用线程返回各自的结果缓冲区(如果需要的话)。
线程局部存储
类似于线程特有数据,线程局部存储提供了持久的每线程存储。
线程局部存储的主要优点在于,比线程特有数据的使用要简单。要创建线程局部变量,只需简单地在全局或静态变量的声明中包含__thread说明符即可。
但凡带有这种说明符的变量,每个线程都拥有一份对变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
// 示例
// 全局变量
static __thread int var1;
extern __thread int var2;
__thread int var3;
使用线程局部变量需要注意以下几个:
线程取消
发送取消请求
函数pthread_cancel()向由 thread 指定的线程发送一个取消请求。发出取消请求后,函数pthread_cancel()当即返回,不会等待目标线程的退出。
取消状态及类型
取消状态和取消类型,决定了目标线程的响应
取消状态
int pthread_setcancelstate(int state, int *oldstate);
函数 pthread_setcancelstate()会将调用线程的取消性状态置为参数 state 所给定的值。
- state参数
PTHREAD_CANCEL_DISABLE
不可取消状态线程不可取消。如果此类线程收到取消请求,则会将请求挂起,直至将线程的取消状态置为启用。
PTHREAD_CANCEL_ENABLE
取消状态线程可以取消。这是新建线程取消性状态的默认值。
线程的前一取消性状态将返回至参数oldstate所指向的位置。
取消类型
只有当线程的取消状态设置为启用时,那么对目标线程对取消请求的处理取决于取消类型
int pthread_setcanceltype(int type, int *oldtype);
- type参数
PTHREAD_CANCEL_ASYNCHRONOUS
异步取消可能会在任何时点(也许是立即取消,但不一定)取消线程
PTHREAD_CANCEL_DEFERRED
延迟取消取消请求保持挂起状态,直至到达取消点,也是默认类型
取消状态及类型对进程的影响
当某线程调用 fork()时,子进程会继承调用线程的取消性类型及状态。而当某线程调用exec()时,会将新程序主线程的取消性类型及状态分别重置为PTHREAD_CANCEL_ ENABLE和PTHREAD_CANCEL_DEFERRED。
取消点
若将线程的取消性状态和类型分别置为启用和延迟,仅当线程抵达某个取消点(cancellation point)时,取消请求才会起作用。
常见取消点:
-
阻塞函数,如pthread_join()、sleep()等
-
手动设置取消点,可以在需要的地方插入取消检查,例如通过调用pthread_testcancel(),这会产生一个取消点,和那些阻塞函数的取消点的作用是一样的,线程执行到该位置,若有取消请求,则线程会直接终止
清理函数
一旦有处于挂起状态的取消请求,线程在执行到取消点时如果只是草草收场,这会将共享变量以及Pthreads 对象(例如互斥量)置于一种不一致状态,可能导致进程中其他线程产生错误结果、死锁,甚至造成程序崩溃。
线程可以设置一个或多个清理函数,当线程 取消 时会自动执行这些执行函数,进行资源的释放等等
每个线程都可以拥有一个清理函数栈。当线程遭取消时,会沿该栈自顶向下依次执行清理函数,首先会执行最近设置的函数,接着是次新的函数,以此类推。当执行完所有清理函数后,线程终止。
通常,只有当线程被取消时才会执行设置的清理函数,正常结束是不需要执行这些清理动作的
线程补充
线程栈
创建线程时,每个线程都有一个属于自己的线程栈,且大小固定。
和进程布局中的栈是差不多的意思,有时候也可以通过也写函数改变线程栈的大小
线程与信号
概述
UNIX 信号模型早于 Pthreads 设计,信号与线程模型之间存在冲突。主要挑战 在于保持传统信号语义,同时支持多线程环境的新信号模型。在多线程程序中处理信号时要特别小心,以避免复杂性
UNIX 信号模型与线程的映射
-
信号动作:
- 信号动作是进程层面的。若任一线程收到未经处理的信号(如 SIGSTOP 或 SIGTERM),则所有线程将被停止或终止。
- 进程中的所有线程共享信号处理设置。使用
sigaction()
设置的信号处理程序会在收到信号时被所有线程调用。
-
信号发送:
- 信号可以发送给整个进程或特定线程。以下三种信号属于线程特定:
- 由硬件异常引起的信号(如 SIGBUS、SIGFPE)。
- 由于对断开管道的写操作引发的 SIGPIPE。
- 通过
pthread_kill()
或pthread_sigqueue()
发送的信号。
- 其他机制(如用户输入、定时器到期等)产生的信号则是进程级的。
- 信号可以发送给整个进程或特定线程。以下三种信号属于线程特定:
-
信号处理:
- 当多线程程序收到信号且存在处理程序时,内核会 随机选择一条线程 来处理该信号。
-
信号掩码:
- 信号掩码是线程特有的,使用
pthread_sigmask()
可以独立阻止或允许信号。 - 每个线程初始时的挂起信号集合为空,可以发送信号给特定线程,如果被阻塞,则会保持挂起状态。
- 信号掩码是线程特有的,使用
-
信号处理函数:
- 如果信号处理程序中断了
pthread_mutex_lock()
,该调用会自动重新开始。 - 对
pthread_cond_wait()
的中断可能导致自动重新开始或返回 0(假唤醒),应用程序需要重新检查条件。
- 如果信号处理程序中断了
-
备选信号栈:
- 每个线程都有独立的备选信号栈,且新线程不继承创建者的信号栈。
信号动作与信号处理的区别:
- 信号动作是系统对信号的预设响应行为(如忽略、终止或执行处理函数),而信号处理则是具体的代码实现,即对信号的响应逻辑
- 信号动作是进程共享的,而信号处理程序可以在各个线程中独立执行(即信号动作是进程级的,信号处理是线程级的)
- 粗略的理解就是,信号动作是 要做什么, 信号处理是 具体怎么做
线程和进程控制
以下是关于多线程程序中exec()
、fork()
和exit()
的关键点总结:
线程与 exec()
- 行为:当任一线程调用
exec()
系列函数时,当前程序将被完全替换。 - 影响:调用
exec()
的线程以外的所有线程将消失,且不会执行线程特有数据的解构函数或清理函数。 - 结果:该进程的互斥量和条件变量将被丢弃,调用线程的线程ID变为不确定。
线程与 fork()
-
行为:当多线程进程调用
fork()
时,仅复制调用该函数的线程到子进程中。 -
影响:
- 其他线程在子进程中消失,且不调用清理函数或解构函数。
- 全局变量和Pthreads对象在子进程中保留,会导致潜在的内存泄漏和不一致状态。
- 如果父进程中有线程锁定互斥量,子进程中的线程无法解锁该互斥量。
-
建议:在多线程程序中调用
fork()
的唯一推荐情况是紧接着调用exec()
。 -
处理机制:使用
pthread_atfork()
注册fork
处理函数,确保在调用fork()
前后进行必要的准备和清理。
线程与 exit()
- 行为:如果任一线程调用
exit()
或主线程执行return
,所有线程都将终止。 - 影响:不会执行线程特有数据的解构函数或清理函数。
总结
exec()
会导致线程和相关资源消失。fork()
可能导致子进程中的状态不一致,推荐后接exec()
。exit()
将终止所有线程,且不执行清理操作。
线程实现模型
实现模型的差异主要集中在线程如何与内核调度实体(KSE,Kernel Scheduling Entity)相映射。KSE是内核分配CPU以及其他系统资源的(对象)单位。
以下是关于线程实现模型的简要总结:
线程实现模型
1. 多对一 (M:1) 实现(用户级线程)
- 概述:所有线程管理(创建、调度、同步)由用户空间的线程库处理,内核对线程不可见。
- 优点:
- 快速的线程操作(创建、终止、上下文切换)因为无需切换到内核模式。
- 移植性好,无需内核支持。
- 缺点:
- 一旦某线程进行阻塞系统调用(如
read()
),所有线程都将被阻塞。 - 内核无法在多处理器平台上调度这些线程。
- 一旦某线程进行阻塞系统调用(如
2. 一对一 (1:1) 实现(内核级线程)
- 概述:每个线程映射一个独立的KSE,内核对每个线程进行调度。
- 优点:
- 阻塞的系统调用不会影响其他线程,内核可在多处理器上调度多个线程。
- 缺点:
- 线程创建和上下文切换速度较慢,需要切换到内核模式。
- 大量线程会增加内核调度器的负担,可能影响系统性能。
- 应用:LinuxThreads和NPTL采用此模型。
3. 多对多 (M:N) 实现(两级模型)
- 概述:多个线程可以映射到多个KSE,结合了M:1和1:1的优点。
- 优点:
- 允许内核将同一应用的线程调度到不同CPU上,避免性能问题。
- 缺点:
- 复杂性高,线程调度由内核和用户线程库共同管理,需要协作和信息交换。
- 管理信号的复杂度增加。
Linux中POSIX线程的实现
1. LinuxThreads
- 概述:LinuxThreads 是早期的POSIX线程实现,最初由Xavier Leroy开发,旨在为Linux提供对POSIX线程标准的支持。
- 实现特点:
- 采用 1:1 模型,即每个用户级线程都有一个对应的内核级线程。
- 提供基本的线程管理功能,如创建、终止、同步和调度。
- 缺点:
- 性能相对较低,尤其在大量线程的情况下,调度和管理开销较大。
- 存在一些稳定性问题,尤其是在与信号处理相关的功能上。
2. NPTL (Native POSIX Thread Library)
- 概述:NPTL 是对 LinuxThreads 的重写,目的是提供更高效、更可靠的线程实现。它是自Linux 2.6内核开始的默认线程库。
- 实现特点:
- 采用 1:1 模型,内核能够更好地支持线程调度,允许每个线程独立进行系统调用。
- 提供了更好的性能和可扩展性,特别是在多处理器环境下。
- 改进了信号处理机制,确保了更高的稳定性。
- 优点:
- 能够有效地管理成千上万的线程,减少了调度开销。
- 支持POSIX线程标准的完整实现,兼容性更好。
总结
- LinuxThreads 是较早的线程实现,提供基本的线程支持,但存在性能和稳定性的问题。
- NPTL 则是现代Linux系统中使用的高效线程库,解决了前者的许多缺陷,支持更复杂的多线程应用。