早期Linux不支持线程,直到1996年,Xavier Leroy等人开发出第一个基本符合POSIX标准的线程库LinuxThreads,但LinuxThreads效率低且问题多,自内核2.6开始,Linux才开始提供内核级的线程支持,并有两个组织致力于编写新的线程库:NGPT(Next Generation POSIX Threads)和NPTL(Native POSIX Thread Library),但前者在2003年就放弃了,因此新的线程库就是NPTL。NPTL比LinuxThreads效率高,且更符合POSIX规范,所以它已经成为glibc的一部分,本书使用的线程库是NPTL。
本章要讨论的线程相关的内容都属于POSIX线程(简称Pthread)标准,不局限于NPTL实现。
线程是程序中完成一个独立任务的完整执行序列,即一个可调度的实体,根据运行环境和调度者的身份,线程可分为内核线程和用户线程。内核线程在有的系统上也称为LWP(Light Weight Process,轻量级进程),运行在内核空间,由内核调度;用户线程运行在用户空间,由线程库调度。当进程的一个内核线程获得CPU的使用权时,它就加载并运行一个用户线程,可见,内核线程相当于用户线程运行的容器,一个进程可以拥有M个内核线程和N个用户线程,其中M<=N,并且在一个系统的所有进程中,M和N的比值都是固定的。按照M:N的取值,线程的实现可分为三种模式:完全在用户空间实现、完全由内核调度、双层调度(two level scheduler)。
完全在用户空间实现的线程无须内核的支持,内核甚至不知道这些线程的存在,线程库负责管理所有执行线程,如线程的优先级、时间片等。线程库利用longjmp函数来切换线程的执行,使它们看起来像是并发执行的,但实际上内核仍然是把整个进程作为最小单位来调度,换句话说,一个进程中的所有线程共享该进程的时间片,它们对外表现出相同的优先级(即所有线程使用相同的优先级,因为它们都是以进程为单位调度的)。对这种实现方式而言,N=1,即M个用户线程对应一个内核线程,而该内核线程对实际就是进程本身。完全在用户空间实现的线程的优点:创建和调度线程都无需内核干预,因此速度快,且它不占用额外内核资源,所以即使一个进程创建了很多线程,也不会对系统性能造成明显的影响。其缺点是:对于多处理器系统,一个进程的多个线程无法运行在不同的CPU上,因为内核是按照其最小调度单位来分配CPU的,且线程的优先级只在相对于进程内的其他线程生效,比较不同进程内的线程的优先级无意义。早期的伯克利UNIX线程就是采用这种方式实现的。
完全由内核调度的模式将创建、调度线程的任务交给了内核,运行在用户空间的线程库无须执行管理任务,这与完全在用户空间实现的线程恰恰相反,因此二者的优缺点也正好互换。较早的Linux内核对内核线程的控制能力有限,线程库通常还要提供额外的控制能力,尤其是线程同步机制,但现代Linux内核已经大大增强了对线程的支持。完全由内核调度的线程实现满足M:1=1:1,即1个用户空间线程被映射为1个内核线程。
双层调度模式是两种实现模式的混合体,内核调度M个内核线程,线程库调度N个用户线程,这种线程实现方式结合了前两种方式的优点,不会消耗过多的内核资源,且线程切换速度也较快,同时还能充分利用多处理器优势。
Linux上两个有名的线程库是LinuxThreads和NPTL,它们都采用1:1方式实现,由于LinuxThreads在开发时,Linux内核对线程的支持还很有限,所以其可用性、稳定性、POSIX兼容性都远不及NPTL,现代Linux上默认使用的线程库是NPTL,用户可用以下命令查看当前系统使用的线程库:
LinuxThreads线程库的内核线程是用clone系统调用创建的进程模拟的,clone系统调用和fork系统调用的作用类似,都创建调用进程的子进程,但我们可以为clone系统调用指定CLONE_THREAD标志,此时它创建的子进程与调用进程共享相同的虚拟地址空间、文件描述符、信号处理函数,这些都是线程的特点,但用进程模拟内核线程会导致很多语义问题:
1.每个线程拥有不同的PID,不符合POSIX规范。
2.Linux信号处理本来是基于进程的,但现在一个进程内部的所有线程都能且必须处理信号。
3.用户ID、组ID对一个进程中的不同线程来说可能不同。
4.进程产生的核心转储文件不会包含所有线程的信息,而只包含该核心转储文件的线程的信息。
5.由于每个线程都是一个进程,因此系统允许的最大进程数就是最大线程数。
LinuxThreads线程库一个有名的特性是所谓的管理线程,它是进程中专门用于管理其他工作线程的线程,其作用为:
1.系统发送给进程的终止信号先由管理线程接收,管理线程再给其他工作线程发送同样的信号以终止它们。
2.当终止工作线程或工作线程主动退出时,管理线程必须等待它们结束,以避免僵尸进程。
3.如果主线程即将先于其他工作线程退出,则管理线程将阻塞主线程,直到所有其他工作线程都结束后才唤醒它。
4.回收每个线程堆栈使用的内存。
管理线程的引入,增加了额外的系统开销,且由于管理线程只能运行在一个CPU上,所以LinuxThreads线程库不能充分利用多处理器系统的优势(所有管理操作只能在一个CPU上完成)。
要解决LinuxThreads线程库的一系列问题,不仅需要改进线程库,最主要的是需要内核提供更完善的线程支持,因此Linux内核从2.6版本开始,提供了真正的内核线程,新的NPTL线程库也应运而生,相比LinuxThreads,NPTL的主要优势在于:
1.内核线程不再是一个进程,因此避免了很多用进程模拟线程导致的语义问题。
2.摒弃了管理线程,终止线程、回收线程堆栈等工作都可以由内核完成。
3.由于不存在管理线程,所以一个进程的线程可以运行在不同CPU上,从而充分利用了多处理器系统的优势。
4.线程的同步由内核来完成,隶属于不同进程的线程之间也能共享互斥锁,因此可实现跨进程的线程同步。
创建和结束线程的API在Linux上定义在pthread.h头文件。
pthread_create函数创建一个线程:
thread参数是新线程的标识符,后续pthread_*函数通过它来引用新线程,其类型pthread_t定义如下:
可见pthread_t是一个整数类型,实际上,Linux上几乎所有资源标识符都是一个整型数,如socket、各种System V IPC标识符等。
attr参数用于设置新线程的属性,给它传递NULL表示使用默认线程属性。start_routine和arg参数分别指定新线程将运行的函数及其参数。
pthread_create函数成功时返回0,失败时返回错误码。一个用户可以打开的线程数不能超过RLIMIT_NPROC软资源限制,此外,系统上所有用户能创建的线程总数也不能超过/proc/sys/kernel/threads-max内核参数定义的值。
线程一旦被创建,内核就可以调度内核线程来执行start_routine函数指针参数所指向的函数了,线程函数在结束时最好调用pthread_exit函数,以确保安全、干净地退出:
pthread_exit函数会通过retval参数向线程的回收者传递其退出信息,它执行完后不会返回到调用者,且永远不会失败。
一个进程中的所有线程都能调用pthread_join函数来回收其他线程(前提是目标线程是可回收的),即等待其他线程结束,这类似回收进程的wait和waitpid系统调用:
thread参数是目标线程的标识符。retval参数是目标线程返回的退出信息。pthread_join函数会一直阻塞,直到被回收的线程结束为止,该函数成功时返回0,失败则返回以下错误码:
有时候我们希望异常终止一个线程,即取消线程,它是通过pthread_cancel函数实现的:
thread参数是目标线程的标识符。pthread_cancel函数成功时返回0,失败则返回错误码。接收到取消请求的目标线程可以决定是否允许被取消以及如何取消,这通过以下函数完成:
state参数用于设置线程的取消状态(即是否允许取消)。type参数设置取消类型(如何取消)。oldstate参数记录线程原来的取消状态。oldtype参数记录线程原来的取消类型。state参数的可选值:
1.PTHREAD_CANCEL_ENABLE:允许线程被取消,它是线程被创建时的默认取消状态。
2.PTHREAD_CANCEL_DISABLE:禁止线程被取消,此时,如果一个线程收到取消请求,则它会被请求挂起,直到该线程允许被取消。
type参数的可选值:
1.PTHREAD_CANCEL_ASYNCHRONOUS:线程随时都能被取消,它使得接收到取消请求的目标线程立即采取行动。
2.PTHRED_CALCEL_DEFERRED:允许目标线程推迟行动,直到它调用了下面几个所谓的取消点函数中的一个:pthread_join、pthread_testcancel、pthread_cond、pthread_cond_wait、pthread_cond_timedwait、sem_wait、sigwait。根据POSIX标准,其他可能阻塞的系统调用(如read、write)也可以成为取消点,但为了安全起见,我们最好在可能被取消的代码中调用pthread_cancel以设置取消点。
pthread_setcancelstate和pthread_setcanceltype函数成功时返回0,失败时返回错误码。
pthread_attr_t结构体表示线程属性:
可见,各种线程属性全部包含在一个字符数组中。线程库定义了一系列函数来操作pthread_attr_t类型的变量,以方便我们获取和设置线程属性,这些函数有:
以下是每个线程属性的含义:
1.detachstate:线程的脱离状态,它有PTHREAD_CREATE_JOINABLE和PTHREAD_CREATE_DETACH两个可选值,前者指定线程是可以被回收的,后者使调用线程脱离与其他进程中线程的同步,脱离了与其他线程同步的线程称为脱离线程
,脱离线程在退出时将自行释放其占用的系统资源,线程创建时该属性的默认值是PTHREAD_CREATE_JOINABLE,此外,我们也可使用pthread_detach回收直接将线程设置为脱离线程。
2.stackaddr和stacksize:线程堆栈的起始地址和大小,一般,我们不需要自己管理线程堆栈,因为Linux默认为每个线程分配了足够的堆栈空间(一般是8MB),我们可使用ulimit -s命令来查看或修改这个默认值。
3.guardsize:保护区域大小,如果guardsize大于0,则系统创建线程时会在其堆栈尾部额外分配guardsize字节的空间,作为保护堆栈不被错误地覆盖的区域,如果guardsize为0,则系统不为新创建的线程设置堆栈保护区,如果使用者通过pthread_attr_setstackaddr(用于设置线程属性对象的堆栈起始地址)或pthread_attr_setstack(用于设置线程属性对象的堆栈大小和起始地址)函数手动设置线程的堆栈,则guardsize属性将被忽略。
4.schedparam:线程调度参数,其类型是sched_param结构体,该结构体目前只有一个整型成员——sched_priority,表示线程的运行优先级。
5.schedpolicy:线程调度参数,其可选值为
(1)SCHED_FIFO:使用先进先出方法调度。
(2)SCHED_RR:采用轮转算法(round-robin)调度。
(3)SCHED_OTHER:默认值,适用于绝大多数情况,它提供了适度的公平性和响应性,但是由于不确定性,不适合需要严格实时性的应用程序。SCHED_FIFO和SCHED_RR都具备实时调度功能,但只能用于以超级用户身份运行的进程。
6.inheritsched:是否继承调用线程的调度属性,可选值如下:
(1)PTHREAD_INHERIT_SCHED:新线程沿用其创建者的线程调度参数,此时忽略新线程的其他调度相关参数。
(2)PTHREAD_EXPLICIT_SCHED:调用者要明确指定新线程的调度参数。
7.scope:线程间竞争CPU的范围,即线程优先级的有效范围,POSIX标准定义了该属性以下取值:
(1)PTHREAD_SCOPE_SYSTEM:目标线程与系统中所有线程一起竞争CPU的使用。
(2)PTHREAD_SCOPE_PROCESS:目标线程仅与其他隶属于同一进程的线程竞争CPU的使用。
目前Linux只支持PTHREAD_SCOPE_SYSTEM这一种取值。
和多进程程序一样,多线程程序也要考虑同步问题。pthread_join函数可看作一种简单的线程同步方式,但它无法高效实现复杂的同步需求,如控制对共享资源的独占式访问、满足某个条件后唤醒一个线程。下面讨论3种专门用于线程的同步机制:POSIX信号量、互斥量、条件变量。
在Linux上,信号量API有两组,一组是第13章中的System V IPC信号量,另一组是我们要讨论的POSIX信号量。这两组接口很相似,且语义完全相同。
POSIX信号量函数的名字都以sem_开头,不像大多线程函数那样以pthread_开头。常用的POSIX信号量函数如下:
上图中函数的第一个参数sem指向被操作的信号量。
sem_init函数用于初始化一个未命名信号量(POSIX信号量API支持命名信号量,但本书不讨论)。pshared参数指定信号量类型,如果值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量可以在多个进程间共享。value参数指定信号量的初始值。初始化一个已经被初始化的信号量将导致不可预期的结果。
sem_destroy函数用于销毁信号量,以释放其占用的内核资源,销毁一个正被其他线程等待的信号量将导致不可预期的结果。
sem_wait函数以原子操作的方式将信号量的值减1,如果信号量的值为0,则sem_wait函数将被阻塞,直到这个信号量具有非0值。
sem_trywait函数与sem_wait函数类似,但它始终返回,而不论被操作的信号量是否具有非0值,相当于sem_wait函数的非阻塞版本。当信号量非0时,sem_trywait函数对信号量执行减1操作,当信号量的值为0时,该函数返回-1并设置errno为EAGAIN。
sem_post函数以原子操作的方式将信号量的值加1,当信号量的值从0变为1时,其他正在调用sem_wait等待信号量的线程将被唤醒。
上图中的函数成功时返回0,失败则返回-1并设置errno。
互斥锁(也称互斥量)用于保护关键代码段,以确保其独占式的访问,这有些像二进制信号量,当进入关键代码段时,我们需要获得互斥锁并将其加锁,这等价于二进制信号量的P操作,当离开关键代码段时,我们需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程,这相当于二进制信号量的V操作。
POSIX互斥锁的相关函数如下:
以上函数的第一个参数mutex指向要操作的目标互斥锁,互斥锁的类型是pthread_mutex_t。
pthread_mutex_init函数用于初始化互斥锁,其mutexattr参数指定互斥锁的属性,如果将它设为NULL,则表示使用默认属性。除了该函数外,我们还可以用以下方式初始化一个互斥锁:
宏PTHREAD_MUTEX_INITIALIZER实际上只是把互斥锁的各个字段都初始化为0。
pthread_mutex_destroy函数用于销毁互斥锁,以释放其占用的内核资源。销毁一个加锁的互斥锁将导致不可预期的后果。
pthread_mutex_lock函数以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被锁上,则pthread_mutex_lock函数将阻塞,直到该互斥锁的占有者将其解锁。
pthread_mutex_trylock与pthread_lock函数类似,但它始终返回,而不论被操作的互斥锁是否已经被加锁,相当于pthread_mutex_lock函数的非阻塞版本。当目标互斥锁未被加锁时,pthread_mutex_trylock函数对互斥锁加锁,当互斥锁已被加锁时,pthread_mutex_trylock函数将返回错误码EBUSY。这里讨论的pthread_mutex_lock和pthread_mutex_trylock函数的行为是针对普通锁而言的,对于其他类型的锁,这两个加锁函数有不同的行为。
pthread_mutex_unlock函数以原子操作的方式给一个互斥锁解锁,此时如果有其他线程在等待这个互斥锁,则这些线程中的某一个将获得它。
上图中的函数成功时返回0,失败则返回错误码。
pthread_mutexattr_t结构体描述互斥锁的属性,线程库提供了一系列函数来操作pthread_mutexattr_t类型的变量,以方便我们获取和设置互斥锁属性,以下是其中一些主要的函数:
本书仅讨论互斥锁的两种常用属性:pshared和type。互斥锁属性pshared指定是否允许跨进程共享互斥锁,其可选值为:
1.PTHREAD_PROCESS_SHARED:互斥锁可以被跨进程共享。
2.PTHREAD_PROCESS_PRIVATE:互斥锁只能和锁的初始化线程隶属于同一个进程的线程共享。
互斥锁属性type指定互斥锁的类型,Linux支持以下4种互斥锁:
1.PTHREAD_MUTEX_NORMAL:普通锁,这是互斥锁的默认类型。当一个线程对一个普通锁加锁后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性,但也容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。
2.PTHREAD_MUTEX_ERRORCHECK:检错锁。一个线程如果对一个自己加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其他线程加锁的检错锁解锁,或对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM。
3.PTHREAD_MUTEX_RECURSIVE:嵌套锁。这种锁允许一个线程在释放锁前多次对它加锁而不发生死锁,但如果其他线程要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或对一个已经解锁的嵌套锁再次解锁,则解锁操作将返回EPERM。
4.PTHREAD_MUTEX_DEFAULT:默认锁。通常被映射为以上三种锁之一。
死锁使一个或多个线程被挂起而无法继续执行,且这种情况还不容易被发现。在一个线程中对一个已经加锁的普通锁再次加锁将导致死锁,这可能出现在设计得不够仔细的递归函数中。另外,如果两个线程按照不同顺序来申请两个互斥锁,也容易产生死锁,如以下代码所示:
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
int a = 0;
int b = 0;
pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;
void *another(void *arg) {
pthread_mutex_lock(&mutex_b);
printf("in child thread, got mutex b, waiting for mutex a\n");
sleep(5);
++b;
pthread_mutex_lock(&mutex_a);
b += a++;
pthread_mutex_unlock(&mutex_a);
pthread_mutex_unlock(&mutex_b);
pthread_exit(NULL);
}
int main() {
pthread_t id;
pthread_mutex_init(&mutex_a, NULL);
pthread_mutex_init(&mutex_b, NULL);
pthread_create(&id, NULL, another, NULL);
pthread_mutex_lock(&mutex_a);
printf("in parent thread, got mutex a, waiting for mutex b\n");
sleep(5);
++a;
pthread_mutex_lock(&mutex_b);
a += b++;
pthread_mutex_unlock(&mutex_b);
pthread_mutex_unlock(&mutex_a);
pthread_join(id, NULL);
pthread_mutex_destroy(&mutex_a);
pthread_mutex_destroy(&mutex_b);
return 0;
}
以上代码中,主线程试图先占有互斥锁mutex_a,然后操作被该锁保护的变量a,但操作完毕后,主线程没有释放互斥锁mutex_a,而是又申请了互斥锁mutex_b,并在两个互斥锁的保护下,操作变量a和b,最后才一起释放这两个互斥锁,与此同时,子线程则按相反的顺序来申请互斥锁mutex_a和mutex_b,并在两个锁的保护下操作变量a和b。我们用sleep函数来模拟两次调用pthread_mutex_lock之间的时间差,以确保代码中的两个线程各自先占有一个互斥锁(主线程占有mutex_a,子线程占有mutex_b),然后等待另一个互斥锁(主线程等待mutex_b,子线程等待mutex_a),这样两个线程就僵持住了,谁也不能继续往下执行,从而形成死锁。如果代码中不加入sleep函数,则这段代码可能能成功运行,从而为程序留下了一个潜在BUG。
如果说互斥锁是用于同步线程对共享数据的访问,那么条件变量则是用于在线程之间同步共享数据的值。条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的线程。
条件变量的相关函数如下:
上图中函数的第一个参数cond指向要操作的目标条件变量,条件变量的类型是pthread_cond_t。
pthread_cond_init函数用于初始化条件变量。cond_attr参数指定条件变量的属性,如果将其设置为NULL,则表示使用默认属性。除了pthread_cond_init函数外,我们还可使用以下方式初始化一个条件变量:
宏PTHREAD_COND_INITIALIZER实际只是把条件变量的各个字段都初始化为0。
pthread_cond_destroy函数用于销毁条件变量,以释放其占用的内核资源。销毁一个正在被等待的条件变量将失败并返回EBUSY。
pthread_cond_broadcast函数以广播方式唤醒所有等待目标条件变量的线程。pthread_cond_signal函数用于唤醒一个等待目标条件变量的线程,至于哪个线程将被唤醒,则取决于线程的优先级和调度策略。有时我们可能想唤醒一个指定的线程,但pthread没有对该需求提供解决方法,但我们可以间接地实现该需求:定义一个能唯一表示目标线程的全局变量,在唤醒等待条件变量的线程前先设置该变量为目标线程,然后采用广播方式唤醒所有等待条件变量的线程,这些线程被唤醒后都检查该变量以判断被唤醒的是否是自己,如果是就执行后续代码,否则返回继续等待。
pthread_cond_wait函数用于等待目标条件变量,mutex参数是用于保护条件变量的互斥量,以确保pthread_cond_wait函数操作的原子性。在调用pthread_cond_wait前,必须确保互斥锁mutex已经加锁,否则将导致不可预期的结果。pthread_cond_wait函数执行时,会把调用线程放入条件变量的等待队列中并投入睡眠,还会将互斥锁mutex解锁,这两个操作是一个原子操作,从而不会导致解锁互斥锁后,将线程放入等待队列并投入睡眠前这段时间窗口内有其他线程修改了条件并调用pthread_cond_signal或pthread_cond_broadcast,从而在调用pthread_cond_wait的线程被投入睡眠前不会有信号到来(只有在睡眠期间收到信号pthread_cond_wait函数才会返回)。当pthread_cond_wait函数成功返回时,互斥锁mutex将再次被锁上。
上图中函数成功时返回0,失败时返回错误码。
为了充分复用代码,同时后文需要,我们将前面讨论的三种线程同步机制分别封装为三个类,实现在locker.h头文件中:
#ifndef LOCKER_H
#define LOCKER_H
#include <exception>
#include <pthread.h>
#include <semaphore.h>
// 封装信号量的类
class sem {
public:
// 创建并初始化信号量
sem() {
if (sem_init(&m_sem, 0, 0) != 0) {
// 构造函数没有返回值,可通过抛出异常来报告错误
throw std::exception();
}
}
// 销毁信号量
~sem() {
sem_destroy(&m_sem);
}
// 等待信号量
bool wait() {
return sem_wait(&m_sem) == 0;
}
// 增加信号量
bool post() {
return sem_post(&m_sem) == 0;
}
private:
sem_t m_sem;
};
// 封装互斥锁的类
class locker {
public:
// 创建并初始化互斥锁
locker() {
if (pthread_mutex_init(&m_mutex, NULL) != 0) {
throw std::exception();
}
}
// 销毁互斥锁
~locker() {
pthread_mutex_destroy(&m_mutex);
}
// 获取互斥锁
bool lock() {
return pthread_mutex_lock(&m_mutex) == 0;
}
// 释放互斥锁
bool unlock() {
return pthread_mutex_unlock(&m_mutex) == 0;
}
private:
pthread_mutex_t m_mutex;
};
// 封装条件变量的类
class cond {
public:
// 创建并初始化条件变量
cond() {
if (pthread_mutex_init(&m_mutex, NULL) != 0) {
throw std::exception();
}
if (pthread_cond_init(&m_cond, NULL) != 0) {
// 构造函数中一旦出现问题,就应立即释放已经成功分配的资源
pthread_mutex_destroy(&m_mutex);
throw std::exception();
}
}
// 销毁条件变量
~cond() {
pthread_mutex_destroy(&m_mutex);
pthread_cond_destroy(&m_cond);
}
// 等待条件变量
bool wait() {
int ret = 0;
// 作者在此处对互斥锁加锁,保护了什么?这导致其他人无法使用该封装类
pthread_mutex_lock(&m_mutex);
ret = pthread_cond_wait(&m_cond, &m_mutex);
pthread_mutex_unlock(&m_mutex);
return ret == 0;
}
// 唤醒等待条件变量的线程
bool signal() {
return pthread_cond_signal(&m_cond) == 0;
}
private:
pthread_mutex_t m_mutex;
pthread_cond_t m_cond;
};
#endif
如果一个函数能被多个线程同时调用且不发生竞态条件,则我们称它是线程安全的(thread safe),或者说它是可重入函数。Linux库函数只有一小部分是不可重入的,如inet_ntoa、getservbyname、getservbyport函数。这些库函数之所以不可重入,主要是因为其内部使用了静态变量,但Linux对很多不可重入的库函数提供了对应的可重入版本,这些可重入版本的函数名是在原函数名尾部加上_r,如localtime函数对应的可重入函数是localtime_r。在多线程程序中调用库函数,一定要使用其可重入版本,否则可能导致预想不到的结果。
如果多线程的某个线程调用了fork函数,那么新创建的子进程只拥有一个执行线程,它是调用fork的那个线程的完整复制,且子进程将自动继承父进程中互斥锁、条件变量的状态,即父进程中已被加锁的互斥锁在子进程中也是被锁住的,这就引起了一个问题:子进程可能不清楚从父进程继承而来的互斥锁的具体状态(是加锁还是解锁状态),这个互斥锁可能被加锁了,但不是由调用fork的线程锁住的,而是由其他线程锁住的,此时,子进程若再次对该互斥锁加锁会导致死锁,如以下代码所示:
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
pthread_mutex_t mutex;
// 子线程运行的还是,它首先获得互斥锁mutex,然后暂停5秒,再释放该互斥锁
void *another(void *arg) {
printf("in child thread, lock the mutex\n");
pthread_mutex_lock(&mutex);
sleep(5);
pthread_mutex_unlock(&mutex);
}
int main() {
pthread_mutex_init(&mutex, NULL);
pthread_t id;
pthread_create(&id, NULL, another, NULL);
// 父进程中的主线程暂停1秒,以确保在执行fork还是前,子线程已经开始运行并获得了互斥量mutex
sleep(1);
int pid = fork();
if (pid < 0) {
pthread_join(id, NULL);
pthread_mutex_destroy(&mutex);
return 1;
} else if (pid == 0) {
printf("I am in the child, want to get the lock\n");
// 子进程从父进程继承了互斥锁mutex的状态,该互斥锁处于锁住的状态
// 这是由父进程中的子线程执行pthread_mutex_lock还是引起的,因此以下加锁操作会一直阻塞
// 尽管从逻辑上来说它是不应该阻塞的
pthread_mutex_lock(&mutex);
printf("I can not run to here, oop...\n");
pthread_mutex_unlock(&mutex);
exit(0);
} else {
wait(NULL);
}
pthread_join(id, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
pthread提供了一个专门的函数pthread_atfork,以确保fork调用后父进程和子进程都拥有一个清楚的锁状态:
pthread_atfork函数将建立3个fork句柄帮助我们清理互斥锁的状态。prepare句柄将在fork函数创建出子进程前被执行,它可以用来锁住父进程中的互斥锁。parent句柄是fork函数创建出子进程后,fork函数返回前,在父进程中被执行,它的作用是释放所有在prepare句柄中被锁住的互斥锁。child句柄是在fork函数返回前,在子进程中执行,它和parent句柄一样,也是用于释放所有在prepare句柄中被锁住的互斥锁。该函数成功时返回0,失败则返回错误码。
要让以上代码正常工作,需要在fork调用前加上以下代码:
void prepare() {
pthread_mutex_lock(&mutex);
}
void infork() {
pthread_mutex_unlock(&mutex);
}
pthread_atfork(prepare, infork, infork);
每个线程都能独立设置线程掩码,进程设置信号掩码的函数是sigprocmask,但在多线程环境下应使用pthread_sigmask函数设置信号掩码:
pthread_sigmask函数的参数与sigprocmask函数的参数完全相同。pthread_sigmask函数成功时返回0,失败返回错误码。
由于进程中所有线程共享该进程的信号,所以线程库将根据线程掩码决定把信号发送给哪个具体的线程。所有线程共享信号处理函数,当我们在一个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一信号设置的信号处理函数。我们应该定义一个专门的线程来处理所有信号,这可通过以下两个步骤实现:
1.在主线程创建出其他子线程前就调用pthread_sigmask来设置好信号掩码,所有新创建的子线程将自动继承这个信号掩码,这样,所有线程都不会响应被屏蔽的信号了。
2.在某个线程中调用以下函数等待信号并处理:
set参数指定要等待的信号的集合,我们可以将其指定为在第一步中创建的信号掩码,表示在该线程中等待所有被屏蔽的信号。参数sig指向的整数用于存储该函数返回的信号值。sigwait成功时返回0,失败则返回错误码。一旦sigwait函数成功返回,我们就能对收到的信号做处理了,显然,如果我们使用了sigwait函数,就不应再为信号设置信号处理函数了。
以下代码取自pthread_sigmask函数的man手册,它展示了如何通过以上两个步骤实现在一个线程中统一处理所有信号:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
// perror函数根据全局errno值打印其相应的错误信息到标准错误
#define handle_error_en(en, msg) \
do { errno = en; perror(msg); exit(EXIT_FAILURE); } while (0)
static void *sig_thread(void *arg) {
sigset_t *set = (sigset_t *)arg;
int s, sig;
for (; ; ) {
// 第二步,调用sigwait等待信号
s = sigwait(set, &sig);
if (s != 0) {
handle_error_en(s, "sigwait");
}
printf("Signal handling thread got signal %d\n", sig);
}
}
int main(int argc, char *argv[]) {
pthread_t thread;
sigset_t set;
int s;
// 第一步,在主线程中设置信号掩码
sigemptyset(&set);
sigaddset(&set, SIGQUIT);
sigaddset(&set, SIGUSR1);
s = pthread_sigmask(SIG_BLOCK, &set, NULL);
if (s != 0) {
handle_error_en(s, "pthread_sigmask");
}
s = pthread_create(&thread, NULL, &sig_thread, (void *)&set);
if (s != 0) {
handle_error_en(s, "thread_create");
}
pause();
}
pthread还提供了pthread_kill函数,使我们可以把信号发送给指定线程:
thread参数指定目标线程。sig参数指定待发送信号,如果sig参数为0,则pthread_kill不发送信号,但它仍会进行错误检查,我们可用此方法检查目标线程是否存在。pthread_kill函数成功时返回0,失败则返回错误码。