理解 Linux 线程

本文深入探讨了线程与进程的概念,解析了线程ID和进程ID的区别,详细介绍了pthread库接口,包括线程的创建、标识、退出、连接与分离等关键操作。同时,文章阐述了互斥量和读写锁的原理与应用,分析了条件等待的机制,以及伪共享对性能的影响。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

线程与进程

进程 ID 和线程 ID

pthread 库接口

线程的创建和标识

pthread_create 函数

参数介绍

返回值

线程 ID 及进程地址空间布局

两类线程 ID 说明

进程地址空间

线程创建的默认属性

线程的退出

线程的连接与分离

线程的连接

为什么要连接退出的线程

线程的分离

互斥量

为什么需要互斥量

互斥量的接口

互斥量的初始化

互斥量的销毁

互斥量的加锁和解锁

临界区的大小

互斥量的性能

互斥锁的公平性

互斥锁的类型

死锁和活锁

读写锁

读写锁的接口

创建和销毁读写锁

读写锁的加锁和解锁

读写锁的竞争策略

读写锁总结

性能杀手:伪共享

条件等待

条件变量的创建和销毁

条件变量的使用


线程与进程

在 Linux 下程序或可执行文件是一个静态实体,它只是一组指令的集合,没有执行的含义。进程是一个动态的实体,有自己的生命周期。线程是操作系统进程调度可调度的最小执行单元,进程和线程的关系可归为四类:

  1. 单线程进程,这就是传统意义上的进程
  2. 多线程进程
  3. 多个单线程进程
  4. 多个多线程进程

为什么要有多线程:简单点说是为了实现并发编程模型,提高机器工作效率。生活中我们去某个机构办理业务时不希望这里只有一个窗口,而是希望窗口越多越好,因为有多个窗口同时处理业务能让我们排队等候的时间变短,整体工作效率也就提高了,这其实就是生活中并发模型。

提出多线程之后随之而来的一个问题是:多个进程也能实现并发编程,为什么又要出现多线程呢。首先多线程和多进程是有区别的,都要自己的特点,进而有各自的优缺点,进而有各自更适合出场的场景。

进程之间,各自的地址空间是独立的,但线程会共享地址空间。同一个进程的多个线程共享一份全局内存区域,包括初始化数据段、未初始化数据段和动态分配的堆内存段。

这种共享给线程带来了很多优势:

  • 创建线程花费的时间要小于创建进程花费的时间
  • 终止线程花费的时间要小于终止进程花费的时间
  • 线程之间上下文切换的开销要小于进程之间上下文切换开销
  • 线程之间数据的共享要比进程之间的共享简单
  • 线程之间通信的代价小于进程之间通信的代价

你可以做一个简单实验:分别创建10万个进程和创建10万个线程,比较二者时间上的开销。

创建进程的测试程序将会执行如下操作:

  1. 调用 fork 函数创建子进程,子进程无实际操作,调用 exit 函数立即退出,父进程等待子进程退出
  2. 重复步骤1,共执行10万次

创建线程的测试程序将会执行如下操作:

  1. 调用 pthread_create 创建线程,线程无实际操作,调用 pthread_exit 函数立刻退出,主线程调用 pthread_join 函数等待线程退出
  2. 重复步骤1,执行10万次

别人机器上对本测试的结论是:创建线程花费的时间约是创建进程花费时间的五分之一。

在上述测试中,调用 fork 函数和 pthread_create 函数之前,并没有分配大块内存,考虑到创建进程需要拷贝页表,而创建线程不需要,则两者之间效率上的差距将会进一步拉大。

别人机器上的测试的结论是:创建线程和进程之前,堆上分配40GB空间,创建线程花费的时间约是创建进程花费时间的五十分之一。

线程间的上下文切换,指的是同一个进程里不同线程之间发生的上下文切换。由于这些线程原本属于同一个进程,他们是共享地址空间的,大量资源共享,切换的代价小于进程间的切换是自然而然的事情了。


没有银弹,多线程带来优势的同时,也存在一些弊端。 

  • 多线程的进程,因地址空间的共享让该进程变得更加脆弱。多个线程之中,只要有一个线程不够健壮存在 bug,就会导致进程内的所有线程一起完蛋。相比之下,进程的地址空间相互独立,彼此隔离的更加彻底。多个进程之间相互协同,一个进程存在 bug 导致异常退出,不会影响到其他进程。
  • 线程模型作为一种并发的编程模型,效率并没有想象的那么高,会出现复杂度高、易出错、难以测试和定位的问题。

目前存在的并发编程,基本可以分为两类:

  1. 共享状态式,这是线程模型采用的方式
  2. 消息传递式

首先,多个线程之间,存在负载均衡的问题,现实中很多情况下很难将全部任务等分给每个线程。想象一下,如果存在10个线程,其中的某一个线程承担了 80% 的任务,其它的9个线程承担了 20% 的任务,整体的效率就降下来了。

其次,多个线程的任务之间还可能存在顺序依赖关系,一个线程未完成某些操作之前,其他线程不能或比应该执行。

多个线程之间需要同步。多个线程在同一进程的地址空间下,若存在多个线程操作共享资源,则需要同步,否则可能会出现结果错误、数据结构遭到破坏甚至程序崩溃的后果。多线程编程中存在临界区的概念,临界区的代码只允许一个线程执行,线程提供锁机制来保护临界区。当其他线程来到临界区却无法申请到锁时,就可能陷入阻塞,不再处于可执行状态,线程可能不得不让出 CPU 资源。如果设计不合理,临界区非常多,线程之间的竞争异常激烈,频繁的上下文切换导致性能急剧恶化。

上面两种情况的存在,决定了多线程并非总是处于并发状态,加速也并非线性的。四个线程未必能带来四倍的工作效率,加速取决于可以串行执行的部分在全部工作中所占的比例。

由于进程调度的无序性,严格来说多线程程序的每次执行其实并不一样,很难群举所有的时许组合,所以我们无法宣称多线程的程序经过了充分的测试。在某些特殊时许的条件下,bug 可能会出现,这种 bug 难以复现,很难排查。所以编程时,需要谨慎设计,以确保程序能够在所有时许条件下正常运行。

对于多线程编程,还存在四大陷阱:

  1. 死锁
  2. 饿死
  3. 活锁
  4. 竞态条件

进程 ID 和线程 ID

在 Linudex 中,目前的线程实现是 Native POSIX Thread Library,简称 NPTL。在这种实现中线程又被称为轻量级进程,每一个用户态的线程在内核中都对应一个调度实体,有属于自己的 task_struct 结构体。

一个用户进程下有 N 个用户态线程,每个线程作为独立的调度实体在内核中都有自己的 task_struct 结构体,用户进程和内核里的进程描述符是 1:N 的关系,POSIX 标准要求进程内的所有线程调用 getpid 时返回相同的 ID,这个 ID 是用户进程 ID。

task_struct 结构体(部分):

struct task_struct{
    ...
    pid_t pid;
    pid_t tgid;
    ...
    struct task_struct *group_leader;
    ...
    struct list_head thread_group;
    ...
}

多线程的进程,又称为线程组,线程组内的每一个线程在内核中都对应一个进程描述符(task_struct 结构体)。task_struct 结构体中 pid 对应的是线程 ID,tgid 对应的是用户进程 ID。

这里介绍的线程 ID 不同于后面讲到的 pthread_t 类型的线程 ID,和进程 ID 一样,线程 ID 是 pid_t 类型的变量,而且是唯一标识线程的一个整形变量,在系统内是独立的。

查看线程 ID:

  •  PID :用户进程 ID
  •  LWP :线程 ID,即 gettid() 系统调用的返回值
  • NLWP :线程组内线程的个数

已知进程 ID,查该进程内线程的个数及其线程 ID:procfs 在 task 下会给进程的每个线程建立一个子目录,目录名为线程 ID

线程组内的第一个线程在用户态被称为主线程,在内核中被称为 Group Leader。内核在创建第一个线程时,会将线程组 ID 的值设置成第一个线程的线程 ID,greap_leader 指向自身,即主线程的进程描述符。所以线程组内存在一个线程 ID 等于进程 ID,而该线程即为线程组的主线程。

至于线程组的其他线程 ID 由内核负责分配,其线程组的 ID 总是和主线程的线程 ID 一致,无论是主线程直接创建出来的线程还是创建出来的线程再次创建的线程,都是这样。

通过 group_leadre 指针,每个线程都能找到主线程。主线程存在一个链表头,后面创建的每一个线程都会链入到该双向链表中。

利用上述结构,每个线程都可以轻松找到线程组的主线程,另一方面,通过线程组的主线程,也可以轻松的遍历其所有的组内线程。

需要强调的一点是,线程和进程不一样,进程有父子进程的概念,但在线程组里所有的线程都是对等关系:

  • 并不是只有主线程才能创建线程,被创建出来的线程同样可以创建线程
  • 不存在父子关系,大家都属于同一个线程组,进程 ID 都相等,group_leader 都指向主线程,而且各有各的线程 ID
  • 并非只有主线程才能调用 pthread_join 连接其他线程,同一线程组内的任何线程都可以对某线程执行 pthread_join 函数
  • 并非主线程才能调用 pthread_detach 函数,其实任意线程都可以对同一线程组内的线程执行分离操作

pthread 库接口

pthread 库的基本接口包括:

  1. pthread_create :创建一个线程
  2. pthread_exit :退出线程
  3. pthread_self :获取线程 ID
  4. pthread_equal :检查两个线程 ID 是否相等
  5. pthread_join :等待线程退出
  6. pthread_detach :设置线程状态为分离状态
  7. pthread_cancel :线程的取消
  8. pthread_cleanup_push pthread_cleanup_pop :线程取消,清理函数注册和执行

线程的创建和标识

pthread_create 函数

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg)

参数介绍

  1. thread :pthread_t 类型的指针,线程创建成功的话,会将分配的线程 ID 添入该指针指向的地址。线程后续的操作将该值作为线程的唯一标识
  2. attr :pthread_attr_t 类型,通过该参数可以定制线程属性,比如可以指定新建线程栈空间的大小,调度策略等。如果要创建的线程无特殊要求,该值设置成 NULL,标识采用默认属性
  3. start_routine :线程需要执行的函数。创建线程是为了让线程执行特定的任务。线程创建成功之后,该线程就会执行 start_routinue 函数,该函数之于线程,就如同 main 函数之于主线程
  4. arg :线程执行 start_routine 函数的参数。当执行函数需要传入多个参数时,线程创建者(一般是主线程)和新建线程约定一个结构体,创建者把信息填入该结构体,再把结构体的指针传给新建线程,新建线程只要解析这个结构体,就能获取到需要的所有参数

返回值

如果创建线程成功,则返回 0;如果不成功,则返回一个非 0 的错误码(判断该函数返回值是要注意)。常见的错误码如下:

  • EAGAIN :系统资源不够,或者创建线程的数量超过系统对一个进程中线程总数的限制
  • EINVAL :第二个参数 attr 值不合法
  • EPERM :没有合适的权限来设置调度策略和参数

线程 ID 及进程地址空间布局

两类线程 ID 说明

pthread_create 函数会产生一个 pthread_t 类型的线程 ID,存放在第一个参数指向的空间内。这里的线程 ID 和前面提到的 pid_t 类型的线程 ID 我们该如何去定位或者看待呢?

pid_t 类型的线程 ID :属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来在整个操作系统内唯一标识该线程。

pthread_t 类型的线程 ID :属于 NPTL 线程库的范畴,线程库的后续操作,就是根据该线程 ID 来操作线程的。对于 Linux 目前使用的 NPTL 实现而言,pthread_t 类型的 ID 本质上是进程地址空间上的一个地址。

线程库 NPTL 提供 pthread_self 函数,可以获取到线程自身的 ID:

#include <pthread.h>

pthread_t pthread_self(void);

在同一个线程组内的,线程库提供了接口,可以判断两个线程 ID 是否对应着同一个线程:

#include <pthread.h>

int pthread_equal(pthread_t t1, pthread_t t2);

返回值是 0 的时候,表示是同一个线程,非 0 则表示不是同一个线程。 

进程地址空间

对于进程地址空间的布局,系统有如下控制选项:

该选项影响进程地址空间的布局,主要是影响 mmap 区域的基地址位置以及 mmap 生长方向两个方面。如果该值为 1,那么 mmap 的基地址 mmap_base 变小(约在用户地址空间的三分之一处),mmap 区域从低地址向高地址扩展。如果该值为 0,那么 mmap 区域的基地址在栈的下面,mmap 区域从高地址向低地址扩展。默认值为 0,布局如下:

可以通过下面两种方式来查看进程地址空间分布:

1. pmap pid 

2. cat /proc/pid/maps

在巨大的用户地址空间里,代码段、已初始化数据段、未初始化数据段,以及主线程的栈,所占用的空间非常小,都是 KB、MB 这个数量级别的。

由于主线程的大小并不是固定的,要在运行时才能确定大小,因此,在栈中不能存在巨大的局部变量,编写递归函数是要小心,递归不能太深,否则很可能耗尽栈空间。

下面这个程序是用于测试主线程栈空间的最大值的:

#include<stdio.h>
 
int i = 0;
void func()
{
         int buffer[256];  //256 * 4 = 1024 1KB
         printf("i = %d\n",i);
         i++;
         func();
}
int main()
{
         func();
         return 0;
}

上面的递归代码,每次递归。都会消耗 1KB 的栈空间。通过运行结果可以看出,主线程栈最大也就 8GB 左右。 

所以进程地址空间中,最大的两块地址空间是内存映射区和堆。堆得起始地址特别低,向上扩展,mmap 区域的起始地址特别高,向下扩展。

用户调用 pthread_create 函数时,glibc 首先要为线程分配线程栈,而线程栈的位置就落在 mmap 区域。glibc 调用 mmap 函数为线程栈分配空间。pthread_create 函数产生的 pthread_t 类型的线程 ID 就是分配出来的空间的地址,更确切地说是一个结构体的指针。

线程 ID 是进程地址空间内的一个地址,在同一个线程组内的比较才有意义。不同线程组内的两个线程,哪怕两者的 pthread_t 值是一样的,也不是同一个线程,这是显而易见的。

pthraed_t 类型的 ID 很可能会被复用,在满足以下条件时:

  1. 线程退出
  2. 线程组的其他线程对该线程执行了 pthread_join,或者线程退出前将分离状态设置为已分离
  3. 再次调用 pthread_create 创建线程

下面是一个测试程序:

#include<stdio.h>
#include<pthread.h>
#define _GNU_SOURCE         /* See feature_test_macros(7) */
#include <unistd.h>
#include <sys/syscall.h>   /* For SYS_xxx definitions */

void* thread_work(void* parm)
{
	int TID = syscall(SYS_gettid);  //获取当前线程 ID
	printf("thread :%d\n",TID);
	printf("thread :%d : pthread_self :%p\n",TID,(void*)pthread_self());
	printf("thread :%d : I will exit now!\n",TID);

	pthread_exit(NULL);
}
int main()
{
	pthread_t tid = 0;
	int ret = 0;

	ret = pthread_create(&tid,NULL,thread_work,NULL);
	pthread_join(tid,NULL);
	
	ret = pthread_create(&tid,NULL,thread_work,NULL);
	pthread_join(tid,NULL);
	return 0;
}

对于 pthread_t 类型的线程 ID,虽然在同一时刻不会存在两个线程的 ID 值相同,但是如果线程退出了,重新创建的线程很可能复用了同一个 pthread_t 类型的 ID。从这个角度看,如果要设置调试日志,用 pthread_t 类型的线程 ID 来标识就不太合适了。用 用 pid_t 类型的线程 ID 则是一个比较不错的选择。

采用 pid_t 类型的线程 ID 来唯一标识线程有以下优势:

  1. 返回类型是 pid_t 类型,进程之间不会存在重复的线程 ID,而且不同线程之间也不会重复,在任意时刻都是全局唯一的
  2. procfs 中记录了线程的相关信息,可以方便查看 /proc/pid/task/tid 来获取线程对应的信息
  3. ps 命令提供了查看线程信息的 -L 选项,可以通过输出中的 LWP 和 NLWP,来查看同一线程组的线程个数及线程 ID 的信息

 另一个有意思的功能是可以给线程起一个名字。。。

线程创建的默认属性

创建线程的第二个参数是 pthread_attr_t 类型的指针,pthread_attr_init 会将线程属性重置为默认值(或者传递空指针也可以,这其实是更常见的用法,因为简单)。

#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);

线程属性及默认值:

调用 pthread_attr_getstack 函数可以返回线程栈的基地址和栈大小。处于可移植性的考虑不建议指定线程栈的基地址。但是有时候会有修改线程栈大小的需要。

一个线程需要分配 8MB 左右的栈空间,就决定了不可能无限的创建线程,在进程地址空间受限的 32 位操作系统里尤为如此。在 32 位系统下,3GB 的用户地址空间就决定了创建线程的个数不会太多。如果确实需要很多的线程,可以调用接口来调整线程栈的大小。

#include <pthread.h>

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

线程的退出

下面的三种方法,线程会终止,但是进程不会终止(如果线程不是进程组里的最后一个进程的话):

  1. 创建线程时的 start_routine 函数执行了return,并且返回指定值
  2. 线程调用 pthread_exit
  3. 其他线程调用 pthread_cancel 函数取消了该线程

如果线程组中的任何一个线程执行了 exit 函数或者主线程在 main 函数中执行了 return 语句,那么整个线程组中的所有线程都会终止。

值得注意的是,pthread_exit 和线程启动函数 start_routine 执行 return 是有区别的。在 start_routine 中调用的任何层级的函数执行 pthread_exit 都会引发线程退出,而 return 只能在 start_routine 内执行才能导致线程退出。

#include <pthread.h>
void pthread_exit(void *retval);

retval 是一个指针,存放线程的临终遗言。线程组内的其他线程可以调用 pthread_join 函数接受这个地址,从而获取到退出线程的临终遗言。如果线程退出时没有什么遗言,则直接可以传递 NULL 指针。

这里需要注意的是不能将线程的遗言存放到线程的局部变量里,因为如果用户写的线程执行函数退出了,线程函数栈上的的局部变量可能就不复存在了,线程的临终遗言也就无法被接收者获取到了。

这里正确的传递方式有:

  • 如果是 int 型的变量,则可以使用 pthread_exit((int*) ret);

tricky 的做法,我们将返回值进行强制类型转化,接收方再把返回值强制转化成 int。但是不推荐使用这种方法。这种方法虽然是奏效的,但是太 tricky 了,C 标准没有承诺这个过程中数据一直保持不变。

  • 使用全局变量返回,其他线程调用 pthread_join 时也可见这个变量
  • 将返回值放到 malloc 在堆上开辟的空间里

因为堆上的空间不会随着线程的退出而释放,所以 pthread_join 可以获取到该存放的值,此时要记得释放空间,否则造成内存泄漏。

  • 使用字符串常量,比如 pthread_exit("Hello Word!"); 字符串常量有静态存储的生命周期

传递线程的返回值,除了 pthread_exit 函数可以做到外,线程的执行函数内 return 时也是可以的,两者的数据类型要保持一致,都是 void*。这也解释了为什么线程的执行函数的返回值总是 void* 类型。

线程退出有一个比较有意思的场景:线程组的其他线程仍在执行的情况下,主线程却调用 pthread_exit 退出了。这会发生什么事情呢?

首先要说明的是,这不是常规的做法,但是如果真的这么做了,那么主线程将进入僵尸状态,而其他线程则不受影响,会继续执行。

线程的连接与分离

线程的连接

线程库提供了 pthread_join 函数用来等待某线程的退出并获取它的返回值。这种操作被称为连接(joining)。

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

该函数的第一个参数为等待线程的 ID,第二参数用来接收返回值。

根据等待的线程是否退出,调用 pthread_join 函数是有如下两种情况:

  1. 等待的线程尚未退出,那么调用 pthread_join 的线程将会陷入阻塞
  2. 等待的线程已经退出,那么 pthread_join 函数会将线程的退出值存放到 reval 指向的位置 

线程的连接操作有点类似于进程等待子进程退出操作,但还是有不同之处的:

  • 进程之间的等待只能是父进程等待子进程,而线程则不然。线程组内的成员是对等关系,只要是在一个线程组内,就可以对另外一个线程执行连接操作。
  • 进程可以等待任意子进程的退出,但是线程的连接没有类似的接口,即不能连接线程组内的任一线程,必须明确指明要连接的线程的线程 ID 

pthread_join 不能连接线程组内任一线程的做法是有意为之的。如果线程能够连接组内的任一线程,那么所谓的任一线程就会包括其他库函数私自创建的线程,当库函数尝试连接私自创建的线程时,发现已经被连接过了,就会返回 EINVAL 错误。如果库函数需要根据返回值来确定接下来的流程,着就会引发严重的问题。正确的做法是,连接已知 ID 的那些线程,就像 pthread_join 函数那样。

当调用失败时,和 pthread_create 函数一样,error 作为返回值返。错误码情况如下:

  • ESRCH :传入的线程 ID 不存在
  • EINVAL :线程不是一个可连接的线程
  • EINVAL :已经有其他线程捷足先登,连接目标线程
  • EDEADLK :死锁,如自己连接自己,或者  A 连接 B,B 又连接 A

该函数之所以能够判断是否死锁或者是否被其他线程捷足先登,是因为目标线程的控制结构体 struct pthread 中,存在如下成员变量,记录了该线程的连接者。

struct pthread *joinid;

该指针存在三种可能: 

  1. NULL :线程是可连接的,但是尚没有其他线程调用 pthread_join 来连接它
  2. 指向线程自身的 struct pthread :表示该线程属于自我了断,执行过分离操作,或者创建线程时,设置的分离属性为PTHREAD_CREATE_DETACHED,一旦退出,则自动释放所有资源,无需其他线程来连接
  3. 指向线程组内其他线程的 struct pthread :表示 joinid 所指向的线程会负责连接

因为有了该成员变量来记录线程的连接着,所以可以判断如下场景:

两者还是有区别的,第一种场景线程 A 连接线程 A,pthread_join 一定会返回 EDEADLK。但是第二种情况大部分情况下会返回 EDEADLK,不过也有例外。不建议两个现成相互去连接。

如果两个线程几乎同时对处于可连接状态的线程执行连接操作怎么办?答案是只有一个线程能够成功,另一个则返回 EINVAL。记住,NTPL 提供了原子性的保证。 

为什么要连接退出的线程

不连接已经退出的线程会怎么样? 如果不连接已经退出的线程,会导致资源无法释放。

对线程不执行连接操作时,已经退出的线程,其空间没有被释放,仍然在进程的地址空间之内,当再次创建新的线程时无法复用刚才退出的线程的地址空间,这就造成了资源的泄露。这简直无法仍受。。。

当线程组内的其他线程调用 pthread_join 连接退出线程时,内不会调用 __free_tcb 函数,该函数负责释放已退出线程的资源。

值得一提的是,纵然调用了 pthread_join,也并没有立即调用 mummap 来释放掉退出线程的栈,他们是被后建的线程复用了,这是 NPTL 线程库的设计。释放线程资源的时候,NPTL 认为进程可能会再次创建线程,而频繁的 mummap 和 mmap 会影响性能,所以 NPTL 将该栈缓存起来,放到一个链表之中,如果有新的创建线程的请求,NPTL 会首先在栈缓存中寻找空间合适的栈,有的话,将该栈直接分配给新建的线程。

始终将线程栈归不还给系统也不合适,所以缓存的栈大小有上限,默认是 40 MB,如果缓存起来的线程栈总空间大于 40MB,NPTL 就会扫描链表中的线程栈,调用 mummap 将一部分空间归还给系统。

线程的分离

如果其他线程并不关心线程的退出状态,那么连接操作就会变为一种负担。这时候你需要的东西是:线程退出时,系统自动将线程相关的资源释放掉,无需等待连接。

NPTL 提供了 pthread_detach 函数来将线程设置成已分离的状态,如果线程处于已分离状态,那么线程退出时,系统将自动回收线程相关的资源。

#include <pthread.h>
int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己执行 pthread_detach 函数,将自身设置成已分离的状态。

线程的状态之中,可连接状态和可分离状态是冲突的,一个线程不能既是可连接的,又是已分离的。因此,如果线程处于已分离状态,其他线程尝试连接线程时,会返回 EINVAL 错误。

pthread_detach 函数出错的情况:

  1. ESRCH :传入的线程 ID 不存在
  2. EINVAL :线程不是一个可连接的线程,已经处于已分离状态 

需要注意的是,我们不能误解处于分离状态的线程,所谓已分离,并不是指线程失去控制, 不归线程组管理,而是指线程退出后,系统会自动释放线程资源。若线程组内的的某个线程执行了 exit 函数,即使是已分离的线程,也仍然会受到影响,一并退出。

将线程设置成已分离状态,另一种方法时在创建线程时,将线程的属性设置为已分离:

#include <pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(pthread_attr_t *attr, int *detachstate);

其中 detachstate 的可能值如下:

  • PTHREAD_CREATE_DETACHED :表示创建出来的线程会处于已分离状态
  • PTHREAD_CREATE_JOINABLE :默认情况,表示创建出来的线程会处于可连接状态 互斥量

互斥量

为什么需要互斥量

大部分情况下线程使用的数据都是局部变量,局部变量是在线程栈空间内,这种情况下,变量只属于单个线程,其他线程无法获取到这种变量。

如果所有的变量都是如此,将会省去很多的麻烦。实际情况是很多的变量是多个线程共享的,这种变量称为共享变量。可以通过数据的共享,完成线程间的交互。

多线程并发地操作共享变量,会带来一些问题。

下面这个程序创建了四个线程,不加任何同步操作,让四个线程去共同操作一个全局变量 global_cnt(初始值为0),每个线程执行 N 次自加操作,结果会是 4*N 吗?

程序中引入了读写锁,来确保四个线程位于同一起跑线,同时开始执行自加操作,不受线程创建先后顺序的影响。创建四个线程之前,主线程先占住读写锁地写锁,任意线程创建好之后,要先申请读锁,申请成功之后方能执行 global_cnt++,但是写锁已经被主线程占据,所以无法执行。待四个线程都创建好之后,主线程会释放写锁,从而保证四个线程并发执行。

#include <stdio.h>
#include <pthread.h>

#define N 100000000  //这是每个线程进行 ++ 操作的次数

int global_cnt = 0;
pthread_rwlock_t rwlock;

void* thread_work(void* param)
{
	int i = 0;
	pthread_rwlock_rdlock(&rwlock);  //新创建的线程先获取到读锁
	for(;i < N;++i)
	{
		global_cnt++;
	}
	pthread_rwlock_unlock(&rwlock);  //释放读锁
	return NULL;
}
int main()
{
	pthread_t tid[4];
	int i = 0;
	int ret = 0;
	
	pthread_rwlock_init(&rwlock,NULL);  //读写锁的初始化

	pthread_rwlock_wrlock(&rwlock);  //主线程占住读写锁的写锁
	for(;i < 4;++i)
	{
		ret = pthread_create(&tid[i],NULL,thread_work,NULL);
		if(ret != 0){
			printf("pthread_create failed!\n");
			continue;
		}
	}
	pthread_rwlock_unlock(&rwlock);  //四个线程创建好之后,主线程释放写锁

	for(i = 0;i < 4;++i)
	{
		pthread_join(tid[i],NULL);  //主线程连接创建的四个线程
	}

	pthread_rwlock_destroy(&rwlock);  //读写锁的销毁

	printf("Theoretical results -> global_cnt = %d\n",4 * N);
	printf("reality -> global_cnt = %d\n",global_cnt);
	return 0;
}

某次运行结果如下:

程序执行的结果显然不是我们期待的结果,那么为什么这里没能得到正确的结果呢?

++ 操作并不是一个原子操作,而是对应了如下三条汇编指令:

  1. Load :将共享变量 global_cnt 从内存加载到寄存器,简称 L
  2. Updata :更新寄存器里面 global_cnt 的值,执行加 1 操作,简称 U
  3. Store :将更新后的值,从寄存器写回到共享变量 global_cnt 的内存地址中,简称 S 

所以,当一个线程将 global_cnt 加载到寄存器但未将更新后的值写回变量地址的这个期间另一个线程也将 global_cnt 加载到自己的寄存器时,最终 global_cnt 的值增加了 1,而不是我们期待的增加 2。

多个线程操作同一个共享变量时应保持以下原则:

  • 代码必须要有互斥行为 :当一个线程在临界区中执行时,不允许其他线程进入该临界区
  • 如果多个线程同时要求执行临界区的代码,并且当前临界区并没有线程在执行,那么只能运行一个线程进入临界区
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入该临界区

所以我们需要一把锁。。。

互斥量的接口

互斥量的初始化

互斥量采用的是 mutual exclusive 的缩写,即 mutex。

正确的使用互斥量来保护共享数据,首先要定义和初始化互斥量。POSIX 提供了两种初始化互斥量的方法:

第一种是将 PTHREAD_MUTEX_INITIALIZER 赋值给定义的互斥量:

#include<pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

如果互斥量是动态分配的,或者需要设置互斥量的属性,那么上面静态初始化的方法就不适用了,NPTL 提供了 pthread_muyex_init() 对互斥量进行动态初始化:

#include<pthread.h>

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

第二个参数是用来设定互斥量属性的。大部分情况下,并不需要设置互斥量的属性,传递 NULL,表示使用互斥量的默认属性。

调用 pthread_mutex_init() 之后,互斥量处于没有加锁的状态。 

互斥量的销毁

在确定不需要互斥量的时候,就要销毁它,在销毁之前,有三点需要注意:

  1. 使用 PTHREAD_MUTEX_INITIALIZER 初始的互斥量不需要销毁
  2. 不要销毁一个已加锁的互斥量,或者正在配合条件变量使用的互斥量
  3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

总之,不用了再销毁,销毁了的互斥量再别去操作。。。

#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);

当互斥量处于已加锁状态,或者正在和条件变量配合使用,调用 pthread_mutex_destory() 会返回 EBUSY 错误码。 

互斥量的加锁和解锁

POSIX 提供了如下接口:

#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);

在调用 pthread_mutex_lock() 时会遭遇以下几种情况:

  • 互斥量处于未锁定状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_mutex_lock() 调用会陷入阻塞,等待互斥量解锁 

 前面的例子加上引进互斥量后结果是我们预期的:

临界区的大小

不能太小,如果太小,可能起不到保护的作用。

不能太大,临界区的代码不能并发,如果临界区太大,就无法充分利用多处理器发挥多线程的作用。

对于被互斥量保护的临界区的代码,一定要好好审视,不要将不相干的代码放入临界区中执行。

互斥量的性能

前面那个例子,四个线程分别对全局变量累加 N 次,使用互斥量版本程序和不使用互斥量版本相比,会消耗更多的时间。

引进互斥量后的程序要消耗更多的时间,原因是:

  1. 对互斥量的加锁和解锁,本身有一定的开销
  2. 临界区的代码不能并发执行
  3. 进入临界区的次数过于频繁,线程之间对临界区的竞争太过激烈,若线程竞争互斥量失败,就会陷入阻塞,让出 CPU,所以执行上下文数据切换的次数远远多于不使用互斥量的程序

那么,互斥量的性能是一个值得去关注的问题,它的性能如何呢?

Linux 下,互斥量的实现采用了 futex 机制,传统的同步手段,进入临界区之前会申请锁,而此时不得不执行系统调用,查看是否存在竞争;当离开临界区释放锁的时候,需要再次执行系统调用,查看是否存在需要唤醒正在等待锁的进程。但是在竞争并不激烈的情况下,加锁和解锁的过程中可能会出现以下情况:

  • 申请锁时,执行系统调用,从用户模式进入内核模式,却发现并无竞争
  • 释放锁时,执行系统调用,从用户模式进入内核模式,尝试唤醒正在等待锁的的进程,却发现并没有进程正在等待锁的释放

考虑到系统调用的实现,这两种情况下耗费了资源,却一无所获。。。

futex 机制的出现有效的解决了这两个问题。它是一种用户态和内核态协同工作的机制。glibc 使用内核提供的 futex 系统调用实现了互斥量。

互斥锁的公平性

互斥锁的类型

死锁和活锁

对于互斥量而言,可能引起的最大问题就是死锁了。最简单、最好构造的死锁就是下面这种场景了:

线程 1 已经成功拿到了互斥量 1,正在申请互斥量 2,而同时在另一个 CPU 上,线程 2 已经拿到了互斥量 2, 正在申请互斥量 1。彼此占有对方正在申请的互斥量,结果就是谁也拿不到想要的互斥量,于是死锁就发生了。

实际工程中死锁可能会发生在复杂的函数调用中。可以想象随着程序复杂度的增加,很多死锁并不像上面的例子那么一目了然:

在多线程程序中,如果存在多个互斥量,一定要小心防范死锁的形成。

存在多个互斥量的情况下,避免死锁最简单的方法是总是按照一定的顺序申请这些互斥量。拿第一个例子说明,如果每个线程都按照先申请互斥量 1,再申请互斥量 2 的顺序执行,死锁就不会发生。有些互斥量有明显的层级关系,但是也有一些互斥量原本就没有特定的层级关系,不过没关系,可以人为干预,让所有的线程必须遵循同样的顺序来申请互斥量。

另一种方法是尝试一下,如果取不到锁就返回。Linux 提供以下接口来实现这种思想:

int pthread_mutex_trylock(pthread_mutex_t *mutex);
//如果互斥量已经被锁定,那么当即返回 EBUSY 错误,而不像 pthread_mutex_lock 一样陷入阻塞
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
                            const struct timespec *restrict abs_timeout);
//提供了一个时间参数 abs_timeout,如果申请互斥量的时候,互斥量已被锁定,那么等待;如果到了设定的时间还没有申请到互斥量,那么返回 ETIMEOUT 错误

这两个函数反应了这种尝试一下,不行就算了的思想。

在实际的应用中,这两个接口的出场率远低于 pthread_mutex_lock 函数。

trylock 不行就回退的思想有可能引发活锁。生活中也经常遇到两个人迎面走来,双方都想给对方让路,但是让的方向却不协调,反而互相堵住的情况。活锁现象与这个场景有点类似。

考虑两个线程,线程 A 申请到锁 mutex_a 后尝试申请锁 mutex_b,失败之后,释放锁 mutex_a,进入下一轮申请;同时线程 B 会因为申请锁 mutex_a 失败,而释放锁 mutex_b,如果两个线程恰好一直保持这种节奏,就可能很长时间内两者都一次次擦肩而过。当然这毕竟不是死锁,终究会有一个线程同时持有两把锁而结束掉这种情况。尽管如此,活锁会降低性能。

读写锁

很多时候对共享变量的访问特点是这样的:大多数情况先线程只是读取共享变量的值,并不修改,只有极少数情况下,线程才会真正的修改共享变量的值。

对于这种情况,线程间读请求是无需同步的,它们之间的并发访问是安全的。然而写请求必须锁住读请求和其他写请求。如果使用互斥量,完全锁住读请求并发,则会造成性能的损失。

处于这种考虑,POSIX 引入了读写锁。。。

读写锁的接口

创建和销毁读写锁

NPTL 提供 pthread_rwlock_t 类型来表示读写锁。和互斥量一样,它也提供了两种初始化的方法:

对于静态变量,可采用 PTHREAD_RWLOCK_INITIALIZER 赋值的方法初始化 :

pthread_rwlock_t  rwlock = PTHREAD_RWLOCK_INITIALIZER;

对于动态分配的读写锁,或者非默认属性的读写锁,需要用 pthread_rwlock_init 函数进行初始化。如果第二个参数为 NULL,那么采用默认属性。对于调用 pthread_rwlock_init() 初始化的读写锁,在不需要读写锁的时候,需要调用 pthread_rwlock_destroy()销毁掉。

#include <pthread.h>
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                        const pthread_rwlockattr_t *restrict attr);

读写锁的默认属性:

  • 竞争范围  PTHREAD_PROCESS_PRIVATE  进程内部竞争读写锁
  • 策略          PTHREAD_RWLOCK_PREFER_REDADER_NP  读者优先

读写锁的加锁和解锁

读写锁又称共享—独占锁,有共享,也有独占。

读锁上锁的接口:

#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock,
                               const struct timespec *restrict abs_timeout);

写锁上锁的接口:

#include <pthread.h>
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock,
                               const struct timespec *restrict abs_timeout);

读锁用于共享模式。如果当前读写锁已经被某个线程已读模式占有了,那么其他线程调用 pthread_rwlock_rdlock 会立即获得读锁;如果当前读写锁已经被某线程以写模式占有了,那么调用 pthread_rwlock_rdlock 会陷入阻塞。

写锁用于独占模式。如果当前读写锁被某线程以写模式占有,则不允许任何读锁请求通过,也不允许任何写锁请求通过,读锁请求和写锁请求都会陷入阻塞,至到线程释放写锁。

无论读锁还是写锁,锁的释放都是一个接口:

#include <pthread.h>
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

无论读锁还是写锁,都提供了 trylock 的功能,当不能获得读锁或者写锁时,调用线程不非阻塞,而会立即返回,错误码是 EBUSY。

无论是读锁还是写锁都提供了限时等待,如果不能获取读写锁,则会陷入阻塞,最多等待时长 abs_timeout,如果仍然无法获取锁,则返回,错误码是 ETIMEOUT。

说到这里关于读写锁还远远没完。。。

读写锁的竞争策略

读写锁的属性是 pthread_rwlockattr_t 类型,属性中有两个部分:lookkind 和 pshared,这里先说 lookkind。

#include <pthread.h>
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

应该有读写锁类型的查看和设置函数,我的系统上没能找到。。。。。。 

所谓 lookkind,表示读写锁表现出什么样的行为艺术。对于读写锁,目前有两种策略,一种是读者优先,一种是写着优先。

读写锁的默认行为是读者优先,那么什么是读者优先呢?

如果当前锁的状态是读锁,并存在写锁请求被阻塞,那么在写锁请求后面到来的读锁请求该如何处理就成了问题的关键。

如果在写锁请求后面到来的读锁请求不被写锁请求阻塞,就可以立即响应,写所的下场可能就比较悲惨。如果读锁请求前仆后继源源不断地到来,只要有一个读锁没完成,写锁就没份,这就是所谓的读者优先。

这种策略是不公平的,极端情况下,写请求很可能被饿死。这就是多线程中的饥饿现象。即某些线程总是得不到锁资源。

晚于写锁请求到来的读锁请求不排队乱加塞的行为引起了写锁申请者的强烈不满:凭啥仅仅因为当前是读锁,比我晚来的读锁申请者就不用排队,直接响应!鉴于此,glibc 又实现了写着优先的策略。

所谓写着优先是指,如果当前是读锁,有很多线程在共享读锁,这是允许的,但是一旦线程申请写锁,在写锁请求后面到来的读锁请求就会统统被阻塞,不能先于写请求拿到锁。 

glibc 是如何做到这点的,它引入了一些变量:

  • _lock :管理读写锁全局竞争的锁,无论是读锁写锁还是解锁,都会执行互斥
  • _writer :写锁持有者的线程 ID,如果为 0,表示当前无线程持有写锁
  • _nr_readers :读锁持有线程的个数
  • _nr_readers_queued :读锁的排队等待线程的个数
  • _nr_writers_queued :写锁的排队等待队列的个数

无论是申请读锁还是申请写锁,还是解锁,都至少会做一次全局互斥锁的加锁和解锁,若不考虑阻塞,单单考虑操作本身的开销,读写锁的加锁解锁开销是互斥锁的两倍。当然,函数结束前或进入阻塞之前,会将全局的互斥锁释放。

下面的讨论先暂时忽略该全局的互斥锁。。。

对于读锁请求而言,如果:

  • 无线程持有写锁,即 _writer = 0
  • 采用的是读者优先策略或者没有写锁等待着(_nr_writers_queued = 0)

当满足这两个条件时,读锁请求都可以立即获得读锁,返回之前 _nr_readers++,表示多了一个线程持有读锁

不满足的话,则执行 _nr_readers_queued++,表示增加一个读锁等待着,然后调用 futex,陷入阻塞。醒来之后,会先执行 _nr_readers_queued--,然后再次判断是否满足上面两个条件。

对于写锁请求而言,如果:

  • 无线程持有写锁,即 _writer = 0
  • 没有线程持有读锁,即 _nr_readers = 0

只要满足上述条件,就会立即拿到写锁,将 _writer 置为线程的 ID。

如果不满足,那么执行 _nr_readers_queued++,表示增加一个写锁等待着线程,然后执行 futex 陷入阻塞。醒来后,先执行 _nr_readers_queued--,然后重新判断上述条件。

对于解锁而言,如果当前锁是写锁,则执行如下操作:

  1. 执行 _writer = 0,表示释放写锁
  2. 根据 _nr_writers_queued 的值判断有没有写锁等待者,如果有,则唤醒一个写锁等待着。如果没有写锁等待着,则判断有没有读锁等待着,如果有,则将所有的读锁等待着全部唤醒。

对于解锁而言,如果当前锁是读锁,则执行如下操作:

  1. 执行 _nr_readers--,表示读锁占有着少了一个
  2. 判断 _nr_readers 是否等于 0,是的话则表示自己是最后一个读锁占有着,需要唤醒写锁等待着或者读锁等待者(根据 _nr_writers_queued 的值判断是否存在写锁等待着,若有,则唤醒一个写锁等待着;如果没有写所等待着,判断是否存在读锁等待着,如果有,则唤醒所有的读锁等待着

从上面流程中发现,写着优先也存在自私的倾向,因为写锁解锁的时候,首先去查看有没有阻塞等待的写锁请求,如果有,先唤醒写锁请求线程。因此如果当前读写锁状态是写锁,同时到来很多读请求和写请求,那么将总是优先处理写请求。如果写锁请求源源不断地到来,那它一样将读锁请求饿死。

通过上面的分析可以看到,如果存在大量的读写请求,竞争非常激烈的条件下,读写锁都存在很大的惯性,如果当前锁的状态是读锁状态,在读者优先的策略下,几乎总是读锁请求先得到响应,写锁被阻塞,因此会出现写请求被饿死的情况。解决方法是设定成写着优先。如果当前锁的状态是写锁,而写锁也远远不断的到来,这时候读请求就会被饿死。

读写锁总结

从宏观意义上看,读写锁要比互斥量并发行好,因为读写锁在更多的时间区域内允许并发。

如果认为读写锁是完美的,以至于认为互斥锁没有存在的必要,那么就 too young,too simple,sometimes naive 了。。。

读写锁存在如下的缺点:

  • 性能 :如果临界区比较大,读写锁高并发的优势就会显现出来。但是如果临界区非常小,读写锁的性能短板就会显现出来。由于读写锁无论是加锁还是解锁,首先都会执行互斥操作,加上读写锁还要维护当前读者线程的个数、写锁等待线程的个数、读锁等待线程的个数,因此这就决定了读写锁的开销不会小于互斥量。
  • 饿死 :互斥量虽然不是绝对意义上的公正,但是线程不会饿死。读写锁在读者优先策略下,写线程可能会被饿死,写者优先的策略下,读线程可能会被饿死
  • 死锁 :读锁是可重入的,这就可能会引发死锁。考虑如下场景,读写锁采用读者优先策略,A 线程已经持有读锁,B 线程申请了写锁,正处于等待状态,而持有读锁的 A 线程再次申请读锁,就会发生死锁

比较适合读写锁的场景是:临界区的大小比较可观,绝大数情况下是读,只有非常少的写。。。

性能杀手:伪共享

多线程情况下,除了以上讨论的互斥量和读写锁对性能的影响,还有一种情况对性能的损害是比较大的,却不想临界区那么明显。这就是有名的伪共享问题。。。

条件等待

条件等待是进程间同步的另一种方法。。。

线程经常遇到这种情况:想要执行,可能要依赖某种关系。如果条件不满足,它能做的事情就是等待,等到条件满足位置。通常条件的达成,很可能取决于另一个线程,比如 生成者—消费者模型 当另外一个线程发现条件符合的时候,它会选择一个时机去通知等待在这个条件上的线程。有两种可能性,一种是唤醒一个线程,一种是广播唤醒其他线程。

就像工厂里生产车间没有原料了,所有生产车间都停工了,工人们都在车间睡觉。突然进来一批原料,如果原料充足,你会发广播通知所有车间,原料来了,快来开工吧。如果进来的原料很少,只够一个车间开工,你可能只会通知一个车间开工。

很自然需要一种机制:线程在条件不满足的情况下,主动让出互斥量,让其他线程去折腾,该线程在此处等待,等待条件的满足。一旦条件满足,线程就可以立刻被唤醒。线程之所以可以安心等待,依赖的是其他线程的协作,它确信会有一个线程在发现条件满足后,会向它发送信号,并且让出互斥量。

此时条件变量出来了。。。

条件变量的创建和销毁

NPTL 使用 pthread_cond_t 类型的变量来表示条件变量。条件变量不是一个值,我们无法给条件变量赋值。一个线程要等待某个事件的发生,或者某个条件的满足,那么这个线程需要的是条件变量:线程等待在条件变量上

和互斥锁一样,条件变量在使用之前要先初始化。

互斥锁有静态初始化,条件变量也一样,简单地把 PTHREAD_COND_INITIALIZER 赋值给 pthred_cond_t 类型的变量就可完成条件变量的静态初始化:

#include<pthread.h>

pthread_cond_t cond = PTHREAD_COND_INITIALIZER; 

动态分配条件变量,或者对条件变量的属性有所定制,则需要用 pthread_cond_init 进行初始化,在不需要条件变量时还要销毁掉条件变量:

#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);
//如果使用默认属性,该函数的第二个参数是 NULL

 对条件变量的初始化和销毁,需要注意:

  • 永远不要用一个条件变量对另一个条件变量赋值,这种行为是未定义的
  • 静态初始化的条件变量,不需要销毁
  • 不要引用已销毁的条件变量,这种行为是未定义的
  • 要调用 pthread_cond_destory 销毁的条件变量可以调用 pthread_cond_init 重新进行初始化

条件变量的使用

条件变量,天生就是与条件的满足与否相伴而生的。通常线程会对一个条件进行测试,如果条件不满足,就等待(pthread_cond_wait),或者等待一段有限的时间(pthread_cond_timedwait)。相关函数接口如下:

#include <pthread.h>
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                           pthread_mutex_t *restrict mutex,
                           const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);

从接口上我们发现,条件等待总是和互斥量绑定在一起。为什么要这样设计? 

条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,那么等待千年也是枉然,所以必须要有一个线程通过某些操作,改变共享数据,使原先不满足的条件变得满足了,并且友好的通知等待在条件变量上的线程。。。

条件不会无缘无故地突然就变得满足,必须会牵扯到共享数据的变化。所以一定要有互斥锁来保护。没有互斥锁,就无法安全的获取和修改共享数据。

下面的伪代码显示 POSIX 如何使用条件变量 v,和互斥量 m 来等待条件地发生:

pthread_mutex_lock(&m);
while(condition_is_false){
    pthread_cond_wait(&v,&m);  //此处会阻塞
}

/*    
如果代码运行到此处,表示我们等待的条件已经满足了,并且在此持有互斥量
在满足条件的情况下,做你想做的事
*/

pthread_mutex_unlock(&m);

pthread_cond_wait 函数只能由拥有互斥量的线程来调用,当该函数返回的时候,系统会确保该函数再次持有互斥量。所以这个接口容易给人一种误解,就是该线程一直在持有互斥量。事实并不是这样的,这个接口向系统申明了我的心在等待,永远在等待之后就把互斥量给释放了。这样其他线程就有机会持有互斥量,操作共享数据,触发变化,使线程等待的条件得到满足。

既然条件变量和互斥量的关系如此紧密,为什么不干脆将互斥量变成条件变量的一部分呢?原因是,同一个互斥量上可能会有不同的条件变量,比如说,有的线程希望队列不空时发送信号,有的线程希望队列满的时候发送信号通知它。

pthread_cond_wait 和 pthread_cond_timedwait 的工作方式几乎是一样的,只是调用时需要一个超时时间。注意这个时间是绝对时间,而不是相对时间。如果最多等待两分钟,那么这个值应该是当前时间加上两分钟。

上面将互斥量和条件变量配合使用的范例种有个很有意思的地方,就是用了 while 语句,醒来之后要再次判断条件是否满足,为什么不用 if 语句,而去用 while 语句呢。

pthread_mutex_lock(&m);
if(condition_is_false){
    pthread_cond_wait(&v,&m);  //此处会阻塞
}

/*    
如果代码运行到此处,表示我们等待的条件已经满足了,并且在此持有互斥量
在满足条件的情况下,做你想做的事
*/

pthread_mutex_unlock(&m);

​

答案是不得不如此。因为唤醒中存在虚假唤醒,换言之,条件尚未满足,pthread_cond_wait 就返回了。在一些实现中,即使没有其他线程向条件变量发送信号,等待此条件变量的线程也可能会醒来。

为什么会有虚假唤醒呢?

这里一个原因是条件满足了发送信号,但等到调用 pthread_cond_wait 的线程得到 CPU 资源时,条件又再次不满足。好在醒来之后再次测试条件是否满足就可以解决虚假等待的问题。

条件等待,把控制权交给了别的线程,它信任别的线程在合适的时机通知它,这是多大的信任啊。如果其他线程忘记发送信号了,那么条件等待的线程就彻底“悲剧”了。

如何发送信号来通知等待的线程,POSIX 提供了两个接口?

#include <pthread.h>
//广播唤醒等待在条件变量上的所有线程,所有的线程被广播唤醒之后,集体争夺互斥锁,没抢到的继续睡。
//从内核中醒来,然后继续去睡,这是一种性能浪费
int pthread_cond_broadcast(pthread_cond_t *cond);
//负责唤醒等待在条件变量上的一个线程
int pthread_cond_signal(pthread_cond_t *cond);

使用通知机制来完成线程同步:发送信号,通知等待在条件变量上的线程,然后解锁互斥量(这个顺序不是必须的,也可以颠倒,标准允许任意顺序执行这两个调用)。

有什么区别吗?

先通知条件变量、后解锁互斥量,效率会比先解锁互斥量、后通知条件变量低。因为先通知后解锁,执行 pthread_cond_wait 的线程可能在互斥量依然处于加锁状态时醒来,发现互斥量仍然没有解锁,就会再次休眠,从而导致了多余的上下文切换。某些实现使用等待变形来优化这个问题:并不真正唤醒执行 pthread_cond_wait 的线程,而是将线程从条件变量的等待队列转移到互斥量的等待队列上,从而消除无谓的上下文切换。

glibc 对 pthread_cond_broadcast 做了类似的优化,即只唤醒一个线程,将其他线程从条件变量的等待队列移到互斥量的等待队列中。

先解锁后通知条件变量,可能会有性能上的优势,但是也会带来其它的问题。如果存在一个高优先级的线程,既等待在互斥量上,也等待在条件变量上,同时还存在一个低优先级的线程,只等待在互斥量上。一旦先解锁互斥量,低优先级的线程可能会抢先获得互斥量,待调用 pthread_cond_signal 之后,高优先级的进程会发现互斥量已经被低优先级的线程抢走了。。。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值