[隐藏]

一、概念

Linux中,可以将进程看作只有一个线程(主线程),线程是一种“轻量级”的进程。一个进程可以有多个线程,每个线程处理各自独立的任务。也就是所谓的“并发(concurrency)”,但并不等同于“并行(parallelism)”。真正意义上的并行只存在于多处理器系统中,而并发也可以存在于单处理器中。并行要求程序能够同时执行多个操作,而并发只要求程序能够假装同时执行多个操作,处理器的数量并不影响程序结构。

各线程有独自的线程ID、寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据,而进程的可执行的程序文本、全局内存和堆内存、栈以及文件描述符对该进程的所有线程都是共享的。

所以线程比进程更加精干、简单、快速。当系统在进程间切换时,与进程相关的所有硬件状态都会失效。有些可能随环境切换过程而改变,如高速缓存和TLB可能需要刷新,即使不立即刷新,这些数据对新进程也是无用的。但是同一进程中的线程共享相同的地址空间,所以线程间切换要比在进程间切换快得多。

使用多线程还有很多好处:

  • 通过为每种事件类型的处理分配单独的线程,能够简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。
  • 多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,而多个线程自动地可以访问相同的存储地址空间和文件描述符。同步更快,编程更自然。
  • 有些问题可以通过将其分解从而改善整个程序的吞吐量。在单进程的情况下,多个任务只能串行完成;如果有多个线程,相互独立的任务的处理就可以交叉进行。
  • 交互的程序同样可以通过使用多线程实现响应时间的改善,多线程可以把程序中处理用户输入输出的部分与其他部分分开。

当然,多线程带来好处的同时也是要付出代价的:

  • 如果单个资源要在多个线程中共享,就必须处理一致性问题,也就是要进行相应的同步操作。如果使用过多的同步就很容易损失性能。
  • 为了编写能够在多个线程中正确工作的代码就必须认真的思考和计划,不得不考虑死锁、竞争、优先级倒置等问题。
  • 调试异步代码也比同步代码困难得多。调试不可避免地要改变事件的时序,如果某个线程因调试陷阱而运行的稍微慢了些,则你要跟踪的问题可能不会再现。跟踪内存错误将更加困难。

那么,在什么时候才使用多线程呢?如果要解决的问题本身就不是并发的,使用多线程只能降低程序的性能并使程序复杂;如果程序中的每一步都需要上一步的结果,使用多线程也不会有任何帮助。

最适合使用线程的是实现以下功能的应用:

  1. 计算密集型应用。为了能在多处理器系统上运行,将这些计算分解到多个线程中实现。
  2. I/O密集型应用。为提高性能,将I/O操作重叠。很多线程可以同时等待不同的I/O操作。

二、使用线程

每个进程都有一个进程ID,每个线程也有一个线程ID;进程ID在整个系统中唯一,而线程ID只在它所属的进程环境中有效。

程序中使用 pthread_t 数据类型表示线程ID。Linux中它是由无符号长整型表示的,但为了可移植性,在程序中不应该对它的实现做出假设。

#include <pthread.h>

int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void *), void *restrict arg);

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

int pthread_equal(pthread_t tid1, pthread_t tid2);

返回值:若相等则返回非0值,否则返回0

pthread_t pthread_self(void);

返回值:调用线程的线程ID

void pthread_exit(void *rval_ptr);

返回值:void

int pthread_detach(pthread_t tid);

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

两个函数的返回值:若成功则返回0,否则返回错误编号

线程可以通过 pthread_create 函数创建。当 pthread_create 成功返回时,由 tidp 指向的内存单元被设置为新创建线程的线程ID;attr 参数用于定制各种不同的线程属性,NULL表示创建默认线程;新创建的线程从 start_rtn 函数(线程的启动例程)的地址开始运行,该函数有一个无类型指针参数 arg,如果需要想 start_rtn 传递多个参数,需要将这些参数放到一个结构中。

需要注意的是,在当前线程从函数 pthread_create 中返回以及新线程被调度执行之间不存在同步关系。即新线程可能在当前线程从 pthread_create 返回之前就运行了。甚至在 pthread_create 返回之前,新线程就可能已经运行完毕了。

线程可以通过调用 pthread_self 获得自身的线程ID。pthread_equal 用来比较两个线程ID是否相同而无需关心 pthread_t 类型的具体实现。线程ID的大小关系是没有意义的,所以 pthread_equal 并不像 strcmp 那样,而是在两个线程ID表示同一个线程时返回非零值。

一个进程可以调用exit、_Exit、_exit 或接受到信号来终止进程。类似的,线程有三种退出方式:

  1. 线程从启动例程中返回,返回值是线程的退出码
  2. 线程可以被同一进程中的其他线程取消
  3. 线程调用 pthread_exit

pthread_exit 的参数 rval_ptr 是一个无类型指针,与传给启动例程的参数类似。进程中的其他线程可以通过 pthread_join 函数访问到这个指针。

调用 pthread_join 的线程将一直阻塞,直到 thread 指定的线程退出。如果线程只是从例程返回,rval_ptr 将包含返回码;如果线程被取消,由 rval_ptr 指向的内存单元就置为 PTHREAD_CANCELED。

我们知道,通常父进程需要调用 wait 族函数等待子进程以免子进程成为僵尸进程,浪费系统资源。在线程中也有类似的概念,为确保终止线程的资源对进程可用,应该在每个线程结束时分离它们。一个没有被分离的线程终止时会保留其虚拟内存,包括它们的堆栈和其他系统资源。分离线程意味着通知系统不再需要此线程,允许系统将分配给它的资源回收。

pthread_detach 函数就是用来分离线程的。如果不想等待创建的某个线程,而且知道不再需要控制它,就可以调用 pthread_detach 来分离它。线程可以分离自己,任何知道其ID的其他线程也可以随时分离它。

调用 pthread_join 函数将自动分离指定的线程,被分离的线程就再也不能被其他线程连接了。对分离状态的线程调用 pthread_join 将返回 EINVAL。所以,如果多个线程需要知道某个特定的线程何时结束,则这些线程应该等待某个条件变量而不是调用 pthread_join。

线程也可以请求取消同一进程中的其他线程。

#include <pthread.h>

int pthread_cancel(pthread_t tid);

int pthread_setcancelstate(int state, int *oldstate);

int pthread_setcanceltype(int type, int *oldtype);

三个函数的返回值:若成功则返回0,否则返回错误编号

void pthread_testcancel(void);

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

void pthread_cleanup_pop(int execute);

默认情况下,pthread_cancel 函数会使由 tid 指定的线程的行为如同调用了参数为 PTHREAD_CANCELED 的 pthread_exit 函数,但是线程可以选择忽略取消请求。注意,pthread_cancel 并不等待线程终止,仅仅是提出请求。如果需要知道线程在何时终止,就必须在取消之后调用 pthread_join 与它连接。

取消有三种模式:

模式 状态  类型 含义
 Off(关) 禁用 二者之一 取消请求被推迟直到启用取消模式
 Deferred(推迟) 启用 推迟 在下一个取消点执行取消
 Asynchronous(异步) 启用 异步 随时执行取消

取消状态是通过 pthread_setcancelstate 函数设置的。它将状态修改为 state ,可以是 PTHREAD_CANCEL_ENABLE(启用) 或 PTHREAD_CANCEL_DISABLE(禁用),默认是启用的。原来的状态存放在 oldstate 中。

取消类型可以通过 pthread_setcanceltype 函数设置。type 的值可以是 PTHREAD_CANCEL_DEFERRED(推迟,默认)或 PTHREAD_CANCEL_ASYNCHRONOUS(异步)。

我们说 pthread_cancel 不等待线程终止,目标线程也不会立即被取消,而是到达取消点后,线程检查是否被请求取消。如果当前的取消状态是 PTHREAD_CANCEL_DISABLE,那么对 pthread_cancel 的调用不会杀死线程;这个取消请求将处于未决状态,直到状态改变为 PTHREAD_CANCEL_ENABLE 时,线程将在下一个取消点上对所有未决的取消请求进程处理。

POSIX 标准规定 pthread_cond_timedwait、pthread_cond_wait、pthread_join、pthread_testcancel 及 read、accept 等会引起阻塞的函数都是取消点,但C库函数都不是取消点。

如果线程在很长一段时间都不会调用规定的取消点函数,那么可以调用 pthread_testcancel 手动添加取消点。

但是如果取消类型被设置为 PTHREAD_CANCEL_ASYNCHRONOUS,那么线程可以在任意时间被取消,而无需遇到取消点。

异步的取消应该极力避免,因为很难正确使用。在异步取消过程中你不得获得任何资源,如加锁互斥量、调用 malloc。

在进程中可以调用 atexit 函数注册在 main 结束后调用的函数。同样,线程也可以安排它退出时需要调用的函数。可以把每个线程考虑为有一个活动的清理函数的栈。调用 pthread_cleanup_push 将清理函数加到栈中,调用 pthread_cleanup_pop 删除最近添加的清理函数。当线程退出时,从最近添加的清理函数开始,各个活动的清理函数都将被调用,当所有清理函数返回时,线程被终止。当 pthread_cleanup_pop 中的参数 execute 为非0时,最近添加的清理函数也将被调用,然后删除。

现在可以看出来进程函数和线程函数之间的相似之处:

进程原语 线程原语 描述
 fork pthread_create 创建新的控制流
 exit pthread_exit 从现有的控制流中退出
 waitpid pthread_join 从控制流中得到退出状态
 atexit pthread_cleanup_push 注册在退出控制流时调用的函数
 getpid pthread_self 获取控制流ID
 abort pthread_cancel 请求控制流的非正常退出

三、线程属性

在使用 pthread_create 时可以传递空指针表示创建一个默认线程,也可以自定义线程的属性。

线程属性是用 pthread_attr_t 结构表示的,它对应用程序是透明的,因此,为了程序的可移植性,不应该对它的实现做任何假设,而是使用相应的函数调用。

#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destory(pthread_attr_t *attr);

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

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);

int pthread_attr_getstack(const pthread_attr_t *restrict attr,
void **restrict stackattr, size_t *restrict stacksize);

int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t *stacksize);

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

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

int pthread_attr_getguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);

int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);

所有函数的返回值:若成功则返回0,否则返回错误编号

这些函数可以分为5组。pthread_attr_init 和 pthread_attr_destory 分别用来初始化和销毁属性变量。接下来的四组分别用于四种线程属性的获取和设置:

名称 描述
detachstate 线程的分离状态属性
stackaddr 线程栈的最低地址
stacksize 线程栈的大小(字节数)
guardsize 线程栈末尾的警戒缓冲区大小

前面介绍了分离线程的概念。如果在创建线程时就知道不需要了解线程的终止状态,则可以将 pthread_attr_t 的 detachstate 属性修改为 PTHREAD_CREATE_DETACHED ,以分离状态启动线程;或者设置为 PTHREAD_CREATE_JOINABLE,正常方式启动线程。

pthread_attr_getstack 和 pthread_attr_setstack 即可以用来管理 stackaddr 属性,又可以管理 stacksize 属性。

对进程而言,虚拟地址空间的大小是固定的,进程中只有一个栈,所以它的大小不需要去管理。但对线程来说,所有的线程共享着同样的虚拟地址空间。如果应用程序使用了太多的线程,使得线程栈的总大小大于可用的虚拟地址空间,这时就需要减小线程默认的栈大小。如果线程调用的函数分配了大量的自动变量或调用的函数涉及很深的栈帧,那么就需要增大栈大小。

可以使用 malloc 或 mmap 分配空间,并用 pthread_attr_setstack 来改变新建线程的栈位置。

如果只想设置或获取栈大小,而不想自己处理线程栈的分配问题,则可以使用 pthread_attr_setstacksize 和 pthread_attr_getstacksize。

guardsize 属性控制着线程栈末尾之后用以避免栈溢出的扩展内存的大小。这个属性默认为 PAGESIZE 个字节。可以把 guardsize 设为0,从而使警戒缓冲区机制失效。同样地,如果 stackaddr 属性被修改,系统就会认为我们会自己管理栈,使警戒缓冲区机制自动失效,等同于将 guardsize 设置为0。

如果 guardsize 被修改了,操作系统可以把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序可能会通过信号接收到出错信息。

线程还有并发度属性,它并没有包含在 pthread_attr_t 结构中。

并发度控制着用户级线程可以映射的内核线程或进程的数目。如果系统的实现在内核级线程和用户级线程之间保持一对一的映射关系,那么改变并发度并不会有什么影响,因为所有的用户级线程都可能被调度。但是如果用户级线程与内核级线程是多对一的话,那么增加可运行的用户级线程数,可能会提高性能。

#include <pthread.h>

int pthread_getconcurrency(void);

返回值:当前的并发度

int pthread_setconcurrency(int level);

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

如果操作系统当前正控制着并发度(之前没有调用过 pthread_setconcurrency),pthread_getconcurrency 将返回0。pthread_setconcurrency 设置的并发度只是对系统的一个建议,系统并保证请求的并发度一定被采用。如果希望系统自己决定使用什么样的并发度,就将其设置为0。

四、线程同步

互斥量

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);

int pthread_mutex_destory(pthread_mutex_t *mutex);

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

五个函数的返回值:若成功则返回0,否则返回错误编号

互斥变量用 pthread_mutex_t 类型表示,在使用互斥变量之前,必须先对它进行初始化,可以把它置为常量 PTHREAD_MUTEX_INITIALIZER ,也可以通过调用 pthread_mutex_init 进行初始化。如果动态地分配互斥量,那么在释放内存前需要调用 pthread_mutex_destroy。

调用 pthread_mutex_lock 对互斥量进行加锁,若互斥量已经加锁,调用线程将阻塞直到互斥量被解锁。调用 pthread_mutex_unlock 可以进行解锁。若当前线程已经锁住互斥量,再次对该互斥量加锁会陷入“自死锁”,使线程永远等待下去。不能解锁一个已解锁的互斥量,也不能解锁由其他线程锁住的互斥量。

如果线程不希望被阻塞,可以使用 pthread_mutex_trylock 尝试对互斥量加锁。如果此时互斥量还未被加锁,那么 pthread_mutex_trylock 将锁住互斥量,并返回0;否则 pthread_mutex_trylock 将失败,不能锁住互斥量,返回 EBUSY。

但是在使用互斥量时需要明白两方面的内容:

  1. 互斥量不是免费的,需要时间来加锁和解锁。锁住较少互斥量的程序通常运行的更快。所以,互斥量应该尽量的少,够用就行,每个互斥量保护的区域应尽可能大。
  2. 互斥量的本质是串行执行。如果很多线程需要频繁地加锁同一个互斥量,则线程的大部分时间就会在等待,这对性能是有害的。如果互斥量保护的代码包含彼此无关的片段,则可以将大的区域分解为几个小的区域来提高性能。这样,任意时刻需要进入小临界区的线程减少,线程的等待时间就会减少。所以,互斥量应该足够多,每个互斥量保护的区域应尽量的小。

所以,为了使代码最优,需要这两方面平衡。如果你开始时使用较大的互斥量,然后当经验或性能数据告诉你哪个地方存在频繁的竞争时,你应改用较小的互斥量。

读写锁

读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是加锁状态要么是未加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有三种状态:读模式下加锁、写模式下加锁、未加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

读写锁又叫共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。当读写锁是写加锁状态时,在解锁之前,所有试图对这个锁加锁的请求都会被阻塞。当读写锁在读加锁状态时,所有对这个锁的读请求都被允许,而写请求将被阻塞直到读锁都没释放。当锁处于读加锁状态并且有写请求被阻塞时,随后的读请求也将被阻塞,这样就避免了读锁被长期占用,而写请求一直无法得到满足。读写锁非常适合于对数据读的次数远远大于写的情况。

读写锁的状态可以用下图表示:

 

读写锁状态转换

读写锁状态转换

与互斥量一样,读写锁也有与之类似的函数:

#include <pthread.h>

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_r *restrict attr);

int ptherad_rwlock_destory(pthread_rwlock_t *rwlock);

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_wrlock_t *rwlock);

所有函数的返回值:若成功则返回0,否则返回错误编号

条件变量

条件变量是用来通知共享数据状态信息的。可以用条件变量来通知队列已空、或队列非空、或任何其他需要由线程处理的共享数据状态。例如,有一个共享的队列,多个线程之间对它进行插入删除操作,那么我们在删除之前就需要判断队列是否为空,如果为空,这个线程就需要等待数据到达。需要注意的是,在判断为空之后,等待之前,可能数据已经到达,所以我们需要在判断之前将队列锁住。另一方面,如果锁住队列之后,这个线程进入睡眠,则其他线程将无法获得锁,往队列里添加数据,所以我们还需要在等待数据之前解锁。又需要注意的是,如果在解锁之后,等待之前其他进程获得了锁,并且往队列里添加了数据,那么这个等待的线程可能永久阻塞,因为它睡眠之后无法得到数据已到达的通知,所以解锁和等待之间需要是原子操作。

条件变量是与互斥量相关、也与互斥量保护的共享数据相关的信号机制。当等待条件变量时,互斥量被其他线程锁住;当线程从条件变量等待中醒来时,互斥量被其他线程释放,它重新锁住互斥量。

但条件变量的作用是发信号,而不是互斥。条件变量不提供互斥,需要一个互斥量来同步对共享数据的访问。

为什么不将互斥量作为条件变量的一部分来创建呢?首先,互斥量不仅与条件变量一起使用,而且还要单独使用;其次,通常一个互斥量可以与多个条件变量相关。例如,队列可以为空,也可以为满,虽然可以设置两个条件变量让线程等待不同的条件,但只能有一个互斥量来协调对队列头的访问。

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);

int pthread_cond_destory(pthread_cond_t *cond);

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex,
const struct timespec *restrict timeout);

int pthread_cond_signal(pthread_cond_t *cond);

int pthread_cond_broadcast(pthread_cond_t *cond);

所有函数的返回值:若成功则返回0,否则返回错误编号

同互斥量一样,可以使用 PTHREAD_COND_INITALIZER 对静态分配的条件变量进行初始化;也可以调用 pthread_cond_init 进行动态初始化。

使用 pthread_cond_wait 等待条件变为真。传递给 pthread_cond_wait 的互斥量对条件进行保护,调用者把锁住的互斥量传给函数,函数把调用线程放在等待条件的线程列表上,然后对互斥量解锁,这两个操作是原子的,如前所述。

pthread_cond_timedwait 和 pthread_cond_wait 类似,只是多了一个 timeout,它指定了等待时间,它的类型是 timespec 结构:

struct timespec {
time_t tv_sec;
long tv_nsec;
}

tv_sec 表示秒数,tv_nsec 表示纳秒。

使用这个结构时,需要指定愿意等待多长时间,时间值是一个绝对数而不是相对数。例如,如果想等待3分钟,就需要把当前时间加上3分钟再转换到 timespec 结构,而不是把3分钟转换成 timespec 结构。例如,可以使用下面的函数:

当到达时间但是条件还没出现时, pthread_cond_timedwait 将返回 ETIMEDOUT。

pthread_cond_signal 和 pthread_cond_broadcast 用于唤醒等待条件变量的线程,区别在于前者发“信号”只唤醒一个线程,而后者发“广播”将唤醒所有等待该条件变量的线程。

通常一个条件变量应该只与一种等待的状态相关。例如,有一个线程在等待队列为空,另一个在等待队列满,我们需要两个条件变量与之关联。但这并不是强制的,如果试图将一个条件变量与多个状态管理关联,或将多个条件变量与一个状态关联,就有可能死锁或者竞争。

如果你为多个状态使用一个条件变量,则不能使用发信号唤醒线程,因为你不能分辨在这种状态下应该唤醒哪个线程,这就可能被唤醒的线程没有在等待这个状态,而等待这个状态将一直等待下去。

如果你确信只有一个线程需要被唤醒,那么应该发信号,它比广播更有效。如果不能确定,应该使用广播,它更委托些,也决不会出错,但这是在使用正确方法的前提下,来看一个例子:

注意 process_msg 中在 while 循环中判断条件。在第一次执行这个循环时,如果队列为空,线程将等待;当被唤醒后,进入下次循环,再次判断队列是否为空,不为空才执行下面的语句,否则继续等待,这么做是很有必要的。如果有多个线程在等待,enqueue_msg 线程发出广播后,线程被唤醒,某一线程处理了队列后,队列再次为空,其他线程应该继续等待,而如果在唤醒后不再次判断一下条件,显然将出错。但这并不意味着把广播方式改为发信号就不存在这样的问题了,因为:

  • 正如前面所说,对于多个状态对应一个条件变量,发广播是必须的,每个线程都需要再被唤醒后再次判断当前状态是否是自己等待的;
  • 唤醒线程并不需要占有互斥量,所以我们把发广播放在了解锁之后。当此线程在发信号或广播之前,有一个 process_msg 线程获得了锁,判断队列非空后将其移出,队列再次为空;信号发出后,唤醒一个线程,如果不再次判断,也会出错。当然也可以把唤醒线程放在解锁之前来避免这种情况。
  • 在等待线程返回时,可能并没有线程广播或发信号。因为在等待期间可能收到信号(这里指POSIX信号,与 pthread_cond_signal 发的信号不同),中断 pthread_cond_wait,使得线程被“假唤醒”。

为了避免麻烦,应该总是在循环中等待条件变量。

信号量

信号量是一种不同的线程同步机制,它有点像互斥量,又有点像条件变量。信号量不像互斥量那样有“主人”,但它有一个计数器,所以信号量不仅可以作为锁使用,也可以用来等待事件的发生。

信号量包括命名信号量和无名信号量。命名信号量主要用在多进程间的同步,这里不再讨论。

#include <semaphore.h>

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

int sem_destroy(sem_t *sem);

int sem_wait(sem_t *sem);

int sem_trywait(sem_t *sem);

int sem_timedwait(sem_t *sem, const struct timespec *timeout);

int sem_post(sem_t *sem);

int sem_getvalue(sem_t *sem, int *sval);

所有函数的返回值:若成功则返回0,否则返回-1,并设置errno

信号量的类型是 sem_t,但可以把它理解一个整数,它的值不能小于0。这个值可以增加(sem_post)或减小(sem_wait),如果这个值已经是0,那么调用 sem_wait 将阻塞线程。

一个无名信号量在使用前必须调用 sem_init 进行初始化。pshared 参数指定这个信号量是在进程中的多个线程间同步还是用于多进程的同步。如果 pshared 的值是0,那么 sem 被用于多线程的同步,并且 sem 应该位于所有线程都可访问的地址;否则用于多进程的同步,并且 sem 应该位于这些进程的共享内存区域。

一个信号量只能初始化一次,否则会出现未定义行为。可能有两种情况导致 sem_init 调用失败:value 的值超过了<limits.h>中定义的 SEM_VALUE_MAX,这个值Linux中的定义是 2147483647,此时 errno 将被设置成 EINVAL;pshared 非零,但系统不支持进程间共享信号量,此时 errno 将被设置成 ENOSYS。

调用 sem_wait 可以等待一个信号量,如果信号量的值大于0,sem_init 将把这个减1,并立即返回;否则调用者将被阻塞直到信号量大于0。

sem_trywait 和 sem_timedwait 与 sem_wait 类似,只是在 sem_trywait 不会阻塞,而是在信号量的值不大于0时返回错误,并将 errno 设置为 EAGAIN。sem_timedwait 可以设置超时时间。

sem_post 用于将信号量的值加1,使得在信号量上等待的线程可以从 sem_wait 返回。

调用 sem_getvalue 可以获得信号量的值,这个值被放在 sval 中返回。如果有线程在信号量上等待,也就是说信号量的值不大于0,那么 sval 的值有两种可能:等于0,或者等于正在等待信号量的线程的数目。Linux只是简单地返回0。

一个信号量在不再使用的时候应该首先调用 sem_destroy 销毁信号量,否则可能会引起内存泄漏,但它并不释放 sem 所指向的内存,需要手动释放这块内存。要销毁的信号量必须被初始化过。在销毁后就不能对这个信号量执行任何操作,除非再次调用 sem_init 进行初始化。需要注意的是,在销毁之前要保证没有线程在这个信号量上等待,否则会引起未定义行为。

五、同步属性

互斥量属性

#include <pthread.h>

int pthread_mutexattr_init(pthread_mutexattr_t *attr);

int pthread_mutexattr_destory(pthread_mutexattr_t *attr);

int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr, int *restrict pshared);

int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);

int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);

int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);

所有函数的返回值:若成功则返回0,否则返回错误编号

按照惯例,pthread_mutexattr_init 和 pthread_mutexattr_destory分别用来初始化和销毁互斥量属性。

我们知道,多个进程可以把同一内存区域映射到它们各自的地址空间中,这时,多个进程访问共享数据就像多个线程访问共享数据一样,也需要同步,这就需要设置它的进程共享属性。进程共享属性可以是 PTHREAD_PROCESS_SHARED 或 PTHREAD_PROCESS_PRIVATE,pthread_mutexattr_getpshared 和 pthread_mutexattr_setpshared 分别用来获取和设置这个属性。当被设置成 PTHREAD_PROCESS_SHARED 时,从多个进程共享的内存区域中分配的互斥量就可以用于这些进程的同步。

POSIX 定义了四种类型属性,它们控制着互斥量的特性:

 互斥量类型 没有解锁时再次加锁 不占用时解锁 已解锁时再次解锁
 PTHREAD_MUTEX_NORMAL  死锁 未定义 未定义
 PTHREAD_MUTEX_ERRORCHECK  返回错误 返回错误 返回错误
 PTHREAD_MUTEX_RECURSIVE  允许 返回错误 返回错误
 PTHREAD_MUTEX_DEFAULT  未定义 未定义 未定义

PTHREAD_MUTEX_NORMAL 是标准的互斥量类型,它不做任何特殊的错误检查或死锁检测;PTHREAD_MUTEX_ERRORCHECK 提供错误检查。

PTHREAD_MUTEX_RECURSIVE 属性允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。用一个递归互斥量维护锁的计数,在解锁的次数和加锁次数不相同的情况下不会释放锁。

PTHREAD_MUTEX_DEFAULT 用于请求默认语义。系统在实现它的时候可以把这种类型自由地映射到其他类型。在Linux中,这种类型映射为普通的互斥量类型。

读写锁属性

#include <pthread.h>

int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

int pthread_rwlockattr_destory(pthread_rwlockattr_t *attr);

int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *restrict attr, int *restrict pshared);

int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared);

所有函数的返回值:若成功则返回0,否则返回错误编号

读写锁只有一个进程共享属性,该属性的语义与互斥量的进程共享属性相同。

条件变量属性

#include <pthread.h>

int pthread_condattr_init(pthread_condattr_t *attr);

int pthread_condattr_destory(pthread_condattr_t *attr);

int pthread_condattr_getpshared(const pthread_condattr_t *restrict attr, int *restrict pshared);

int pthread_condattr_setpshared(pthread_condattr_t *attr, int pshared);

所有函数的返回值:若成功则返回0,否则返回错误编号

条件变量也只有一个进程共享属性,该属性的语义与互斥量的进程共享属性也相同。

六、线程私有数据

进程中的所有线程都可以访问进程的整个地址空间,一个线程真正拥有的唯一私有存储是处理器寄存器,甚至栈地址也能被共享,底层实现也没有阻止这种访问。但处理线程私有数据的函数可以提高线程间数据的独立性,维护基于每个线程的数据。

在什么时候需要使用这种机制呢?在需要一个变量时,如果所有线程共享相同的值,则可以使用静态或外部数据,就像在单线程程序中那样,但通常需要互斥量来同步跨越多个线程对共享数据的存取;如果每个线程都需要一个私有变量值,则必须在某处存储所有值,并且每个线程能够定位到属于自己的值,线程私有数据机制可以做到这一点。

在分配线程私有数据之前,需要创建与该数据关联的键,然后每个线程就能独立地设定或取得自己的键值。键对所有的线程是相同的,但每个线程能将它独立的键值与共享的键关联。每个线程能在任何时间为键设置它的私有值,而不会影响到其他线程的键值。

私有数据键的类型是 pthread_key_t ,它也是不透明的,所有不要做任何关于该结构的定义的假设。

#include <pthread.h>

int pthread_key_create(pthread_key_t *key, void (*destructor)(void *));

int pthread_key_delete(pthread_key_t *key);

两个函数的返回值:若成功返回0,否则返回错误编号

pthread_key_create 用于创建一个键,同时可以为该键指定析构函数,当线程调用 pthread_exit 或线程返回,正常退出时,如果该键有析构函数并且和该键关联的数据非空,那么这个函数就会被调用,如果 destructor 被设置成NULL,就表明没有析构函数。如果线程调用了 exit、_Exit、_exit、abort或非正常退出时,即使设置了析构函数也不会调用。析构函数的唯一参数就是该键的数据地址。

析构函数通常用来释放堆内存,防止内存泄漏。当一个线程退出时,系统将检查这个线程所有的线程私有数据键,并且将所有不为空的私有数据键置为空,然后调用该键的析构函数。当所有析构函数都被调用一次后,系统还会检测是否还有非空的私有数据键值的关联,如果有,会再次调用析构函数。如果某个析构函数又设置了键值的关联,那么调用析构函数的过程就可能不会结束,从而进入无限循环,实际上,<limits.h>中定义了 PTHREAD_DESTRUCTOR_ITERATIONS,规定系统检查私有数据键列表的最大次数。另外,这些析构函数的调用顺序是不确定的,所以应该让每个析构函数尽可能独立。

标准规定了一个线程中最多可以有 PTHREAD_KEYS_MAX 个线程私有数据键,这个限制的最小值是128,Linux中的定义是1024.

调用 pthread_key_delete 可以取消键与值之间的关联,但它不会激活析构函数,也不会自动释放私有数据值的内存空间。

对于每个 pthread_key_t 变量只能有一个 pthread_key_create 调用与之对应,如果一个键创建了两次,第二次创建的键将覆盖第一次,第一次的键和任何线程为其设置的值都将丢失。

为了避免这种情况,可能会考虑这样写代码:

这样并不能解决问题,因为判断 init_done 变量与创建的动作不是原子的。最好的方法是使用 pthread_once。

#include <pthread.h>

pthread_once_t initflag = PTHREAD_ONCE_INIT

int pthread_once(pthread_once_t *initflag, void (*initfn)(void));

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

initfalg 必须是一个全局变量或静态变量,而且必须初始化为 PTHREAD_ONCE_INIT。它被称之为控制变量,pthread_once 的第二个参数就是与控制变量关联的函数指针,它所指的函数没有参数。

pthread_once 首先检查控制变量,以判断是否已经完成初始化。如果完成,pthread_once 简单地返回;否则,pthread_once 调用初始化函数。如果一个线程在初始化过程中,另外的线程也调用了 pthread_once,这后者将等待,直到前面的线程初始化完成。

pthread_once 与 pthread_key_create 结合起来使用就可以避免键被创建多次:

键被创建之后,就可以设置和获取私有数据了。

#include <pthread.h>

void *pthread_getspecific(pthread_key_t key);

返回值:线程私有数据值,若没有值与键关联则返回NULL

int pthread_setspecific(pthread_key_t key, const void *value);

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

七、线程与信号

仅仅是进程的信号机制就够复杂的了,在多线程模型中,信号处理就变得更加复杂。

我们可能会面临这样的问题:信号掩码和处理函数应该是私有还是公有?一个信号发生时,应该递送给每个线程还是只递送一次?当信号对应的动作是终止目标时是只终止接收到信号的线程还是终止整个进程?

首先,当线程被创建时,它会继承进程的信号掩码,然后它的掩码就是私有的了,所以可以在进程中设置信号掩码,随后创建的线程就都会屏蔽信号了。

其次,多个线程是共用进程的地址空间的,所以各个线程的某个信号处理函数都是相同的,这样如果在某个线程中忽略了一个信号,随后的一个线程选择执行某个处理函数,那么在前者接收到信号时也会执行这个函数。也就是说后来的信号处理设置会覆盖前面的设置。

再者,每个信号只会被递送给一个线程。这个信号被递送给哪个线程也是不确定的,但是由硬件错误或定时器引起的信号会被递送到引起这个状况的线程。但是 alarm 定时器是所有线程共享的资源,所以在多个线程中同时使用 alarm 还是会互相干扰。

最后,所有影响进程的信号还会影响进程,这意味着向进程或任一线程发送 SIGKILL 信号仍会杀死整个进程,SIGSTOP 信号会导致所有线程停止。这也适用与其他信号的默认行为,比如 SIGSEGV,如果引起这个错误的线程并没有捕获这个信号,而是使用默认动作,那么这个信号还是会终止整个进程。

我们来看看多线程模型中如何使用信号机制。

在进程中可以调用 sigprocmask 来阻止信号发送,但在多线程的进程中它的行为并没有定义,它可以不做任何事情。可以调用在主线程中调用 pthread_sigmask 使得所有线程都阻塞某个信号,也可以在某个线程中调用它来设置自己的掩码。

#include <signal.h>

int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);

int sigwait(const sigset_t *restrict set, int *restrict signop);

两个函数的返回值:若成功则返回0,否则返回错误编号

int sigwaitinfo(const sigset_t *set, siginfo_t *info);

int sigtimedwait(const sigset_t *set, siginfo_t *info, const struct timespec *timeout);

两个函数的返回值:若成功则返回信号编号,否则返回-1,并设置errno

pthread_sigmask 与 sigprocmask 基本相同,除了在失败时 pthread_sigmask 返回错误码而不像 sigprocmask 返回-1并设置 errno。

sigwait 用于等待信号的发生。set 参数指定等待的信号集。signop 作为返回值,表明接收到的信号编号。如果信号集中的某个信号在sigwait 调用的时候处于未决状态,那么 sigwait 将立即返回。

使用 sigwait 可以有效的简化信号的处理。因为信号可以被递送到任一线程,如果多个线程等待同一信号,当信号发生时,只有一个线程可以从 sigwait 中返回,其他线程继续阻塞。同时为了防止线程被不必要地打断,可以把信号加到每个线程的信号屏蔽字中,再专门安排一个线程用于信号处理。为了避免错误,在处理信号的线程中,在调用 sigwait 之前,它要等待的信号也应该是阻塞的。sigwait 会自动取消信号集的阻塞状态,直到有新的信号到达。在返回之前,sigwait 将恢复线程的信号屏蔽字。这样我们就把异步产生的信号用同步的方式来处理。例如信号处理线程有如下代码

如果在调用 sigwait 之前那些信号已经被阻塞,那么在 sigwait 返回后那些信号还是被阻塞的,也就不会打断下面的处理过程。

sigwaitinfo 和 sigtimedwait 与 sigwait 类似。只是收到的信号会以 siginfo_t 类型返回,并且在出错时会设置 errno。sigtimedwait 允许设置在指定时间内没有收到信号就返回(设置errno) EAGAIN 错误。

可以使用 pthread_kill 来发送一个信号

#include <signal.h>

int pthread_kill(pthread_t thread, int signo);

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

可以发送一个0号信号来测试线程是否存在,但是如果信号的默认动作是终止该进程,那么发送该信号会导致整个进程终止。

八、线程与fork

在进程中调用 fork 可以产生一个子进程,调用 pthread_create 可以创建一个线程。那么在线程中调用 fork 会是什么情况呢?

在进程中调用 fork 会复制整个进程地址空间,在线程中调用 fork 也是如此。这是不是意味着如果父进程有多个线程,那么线程中 fork 的子进程也会有多个线程呢?答案是否定的,在子进程内部只有一个线程,它是调用fork的那个线程的副本。

这其实很容易理解。在Linux内核中,无论线程还是进程,都是由 task_struct 结构表示的。fork 一个进程就是创建一个 task_struct,pthread_create 一个线程也是如此。既然在线程中 fork 只会产生一个新的 task_struct 结构,那么当然也就意味着只产生了一个进程。

这个新的 task_struct 就是调用 fork 的线程的 task_struct 的副本,所以在线程中占用了哪些锁,在子进程中也会占用哪些锁。如果在fork后马上调用 exec 当然不会出现什么问题,但如果子进程还要继续处理工作,那子进程就可能面临麻烦。因为子进程并不知道父进程中其他线程的信息,更不知道它正在占用哪些锁,要什么时候释放这些锁。另一个问题是,父进程中的其他线程只是简单不再存在,如果它们有线程私有数据,它们也不会调用也无从调用私有数据析构函数,其他的清理函数也不会被调用,这就可能在子进程出现内存泄漏。解决这些的最好方法是,不在线程中使用 fork,除非子进程会很快地调用exec。如果不得不这么做,就有可能要用到 fork 处理器:

#include <pthread.h>

int pthread_atfork(void (*prepare)(), void (*parent)(), void (*child)());

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

它的三个参数都是函数指针。prepare 的调用在父进程 fork 子进程前调用,可以用来获取父进程中定义的所有锁;parent 在 fork 子进程后,但在 fork 返回之前在父进程中调用,可以用来对 prepare 中获得的锁解锁;childparent 一样,也用来解锁,但它在子进程中进行。这样看起来好像对锁执行了两次解锁操作,其实因为子进程中拥有的是所有锁的副本,所以实际上对每个锁只解锁了一次。

如果为了避免父进程中的现在有私有数据而导致内存泄漏,可以在 prepare 中取消那些线程,在 parent 中创建新的线程。这显然不是一个很好的方法,因为最好的方法还是不要在线程中 fork。

九、实时调度

实时调度是为了尽可能快速地启动一个任务。

实时可分为硬实时和软实时。硬实时有严格的时间限制,要求某些任务必须在指定的时限内完成,这并不意味着所要求的时间范围特别短,而是系统必须保证决不会超过某一时间范围。软实时也要求尽可能在要求的时间内完成任务,但偶尔得不到满足也不会造成严重后果。

POSIX 标准提供的实时调度模型允许程序员指定线程实时调度优先级,从而表现哪个线程更重要一点。

在Linux中实时调度的优先级的范围是从1到99。在内核中,所有就绪了的优先级相同的实时进程(Linux内核中不区分进程和线程)都保存在一个链表中。下图说明了这种组织形式:

实时调度器的就绪队列

标准规定了两种实时调度策略:SCHED_RR 和 SCHED_FIFO。SCHED_RR (轮循)有时间片,在运行时间超过这个时间后会被阻塞;SCHED_FIFO(先进先出)允许一个线程一直运行直到有优先级高的进程抢占它或者自己被阻塞(比如IO请求)。

优先级高的线程总是能够抢占低优先级线程,被抢占的线程将被防止在它所在链表的队首,以便在高优先级线程退出后可以继续之前的运行。如果是时间片用完或者自己被阻塞,则该线程被放置在队末。一个刚刚就绪的线程也会被放置在队末。

#include <pthread.h>

int sched_get_priority_max(int policy);

返回值:若成功则返回相应调度策略的有效优先级的最大值,否则返回-1并设置errno

int sched_get_priority_min(int policy);

返回值:若成功则返回相应调度策略的有效优先级的最小值,否则返回-1并设置errno

int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param);

int pthread_getschedparam(pthread_t thread, int *policy, struct sched_param *param);

int pthread_setschedprio(pthread_t thread, int prio);

int pthread_yield (void);

int pthread_attr_setschedparam(pthread_attr_t *attr, const struct sched_param *param);

int pthread_attr_getschedparam(pthread_attr_t *attr, struct sched_param *param);

int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);

int pthread_attr_getschedpolicy(pthread_attr_t *attr, int *policy);

int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);

int pthread_attr_getinheritsched(pthread_attr_t *attr, int *inheritsched);

十个函数的返回值:若成功则返回0,否则返回错误编号

struct sched_param 结构指定了调度参数,它至少包含了 int sched_priority 成员(Linux中只包含这一个成员),这个成员表示调度优先级。

sched_get_priority_max 和 sched_get_priority_min 分别用来获取 policy 调度策略的有效优先级的最大或最小值。

pthread_setschedparam 和 pthread_getschedparam 分别用来设置和获取线程的调度策略和优先级。pthread_attr_setschedparam、pthread_attr_getschedparam、pthread_attr_setschedpolicy 和 pthread_attr_getschedpolicy 的作用也很明显,用来设置或获取线程属性的调度参数和调度策略,被设置后的线程属性就可以用在 pthread_create 中了。在线程属性中设置调度策略或优先级时,必须同时设置 inheritsched 属性,通过调用 pthread_attr_setinheritsched 来设置。当 inheritsched 属性被设置成 PTHREAD_INHERIT_SCHED 时,新创建的线程将继承创建线程的调度策略和参数;当 inheritsched 属性被设置成 PTHREAD_EXPLICIT_SCHED 时,将使用 pthread_attr_setschedparam 和 pthread_attr_setschedpolicy 设置的参数和调度策略。

十、线程编程模型

流水线

流水线模型指的是每个线程反复地在数据系列集上执行同一种操作,并把操作结果传递给下一步骤的其他线程。在流水线模型中,“数据元素”流串行地被一组线程顺序处理,每个线程依次在每个元素上执行一个特定的操作,并将结果传递给流水线中的下一个线程。

流水线模型

流水线模型

工作组模型

工作组中的每个线程在自己的数据上执行操作,它们可能执行相同的操作,也可能执行不同的操作,但它们一定独立的执行。例如,如果需要对某一个数组进行操作,可以建立一组线程,每个线程处理某行或某列。

工作组模型

工作组模型

客户/服务器模型

在这种模型中,客户请求服务器对一组数据执行某个操作,服务器建立一个线程来处理请求,在另一个客户请求到达时服务器再使用另一个线程进行处理。

客户服务器模型

客户服务器模型

十一、线程安全

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。

更多可以参考 Linux/Unix 编程中 POSIX 函数的线程安全问题