目录
初识线程
线程的概念
Linux下的线程
线程优缺点
线程控制
线程创建
线程终止
线程等待
线程分离
线程取消
其它
线程互斥
互斥的概念
互斥锁的使用
锁的本质
线程同步
线程同步的概念
条件变量的概念
条件变量的使用
信号量
信号量的概念
信号量接口的使用
常见锁的概念
互斥锁与自旋锁
乐观锁与悲观锁
其它
经典同步问题
哲学家就餐问题与死锁
生产者-消费者模型
读写者模型与读写锁
内容补充
线程的局部存储
volitale关键字
其它概念
初识线程
线程的概念
线程(thread)是操作系统能够调度的最小单位,它被包含在进程之中。一个线程就是进程中的一个执行流,所以一个进程中可以存在多个线程,多个线程之间并行或并发地执行不同的任务。也就是说,进程是承担系统资源分配(CPU、内存等资源)的基本单位,而线程是CPU调度的基本单位。
线程是进程内部的执行流,所以同一进程中的多个线程共享该进程中的大多数资源,如虚拟地址空间、页表、文件描述符表和信号处理表等等。但每一个线程又都有各自的栈空间、寄存器信息、线程上下文等。
在单CPU单核心的情况下,同一进程的多个线程之间是并发地执行的,而在多核心的情况下,多个线程之间是并行地执行的(真正意义上的同时运行)。其中的渊源,可以参考:线程级并行。特别的,单CPU多核心可以使多个线程并行地执行,但同一时间只能运行一个进程。多CPU的情况下既可以使多线程并行执行,也可以使多进程并行地执行。
Linux下的线程
不同的操作系统对线程的实现有所不同,在Windows下,是以内核级线程的形式存在的,而在Linux下,线程是以轻量级进程的形式体现的。也就是说,Linux下的线程是复用了进程的结构的。而我们知道,每一个进程都有一个唯一的进程PID用于标识,所以Linux中的每一个线程也都有一个唯一的编号(LWP)用于标识。也就是说,在Linux底层,任务调度看的是LWP,而不是PID。其中,我们可以使用 ps -aL 指令查看当前终端下的线程,解释如下:
-a 选项表示列出所有进程
-L 选项会使得 ps 输出中不仅包含进程的基本信息,还包括其下属线程(轻量级进程)的LWP。
其中,如果一个进程只有一个执行流,那么线程PID就等于这个执行流线程的LWP。
但归根结底Linux下并没有真正的线程,有的只是轻量级进程。而在Linux下,可以通过诸如clone等函数来模拟出一个线程。但手动封装的使用成本太高,而且不具备统一性。所以Linux是自带了一个POSIX标准的pthread线程库的。而pthread库本身就是Linux的一部分,所以pthread又叫pthread原生线程库。
这个pthread库本质上就是封装了一些底层的系统调用,为我们封装了一个线程出来,所以Linux的线程被称之为用户级线程。也就是说,这个pthread库对上层用户提供的是线程的接口,其实底层依旧是使用的轻量级进程相关的系统调用。但由于pthread库并不是C或者C++标准的,相当于一个第三方库。所以我们在使用gcc或g++编译时需要额外添加 -lpthread 选项指定连接的库文件名称,由于pthread库在 /usr/include/ 目录下,所以就不需要再指定路径了。
事实上,pthread库是一个共享库(动态库),所以可执行程序在运行时,pthread是被加载到共享区的。而pthread库在封装管理一个线程时,是会设置一些线程的属性的,这些线程属性就构成了一个线程属性集 struct pthread_attr_t:
typedef struct
{
int etachstate; //线程的分离状态
int schedpolicy; //线程调度策略
structsched_param schedparam; //线程的调度参数
int inheritsched; //线程的继承性
int scope; //线程的作用域
size_t guardsize; //线程栈末尾的警戒缓冲区大小
int stackaddr_set; //线程的栈设置
void* stackaddr; //线程栈的位置
size_t stacksize; //线程栈的大小
} pthread_attr_t;
而为了方便,pthread库中的线程id就直接设为了pthread变量中线程属性集在共享区中的地址。所以pthread库的线程id并不等同于内核的LWP。也就是说在Linux底层按照LWP进行调度,虽然pthread库封装了LWP,但我们无法通过pthread库直接看到LWP。
线程优缺点
线程的特点
- 线程的创建,删除与调度比进程的更快捷。且线程占用的资源也要比进程少很多。
- 线程的调度切换比进程切换快捷的原因在于:首先由于进程的的多个线程共享该进程的大多数资源,所以同一进程的线程之间在进行调度切换时,只需要将每个线程独有的CPU上下文、栈空间等信息切换即可。而且,由于CPU的局部性原理,线程切换可能不需要重新加载CPU内的catch缓存,而进程切换一定会重新加载catch。
- 同一进程中的多个线程共享该进程中的大多数资源,如PID信息、虚拟地址空间、页表、文件描述符表和信号处理表等等。但每一个线程又都有各自的栈空间、寄存器信息、线程上下文等。
- 同一进程下的每个线程信号的未决和递达状态是私有的,但信号的理方法却是共有的。
- 线程的CPU上下文是线程的私有数据,而内存中的数据是被所有线程所共享的。
线程 VS 进程
- 线程不能独立执行程序,必须依附于某个进程。且一切进程至少都有一个执行流(线程)。
- 多线程的情况下,先执行哪个线程是不确定的。
- 多线程能够充分利用多核或者多处理器的并行处理特性。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 对于计算密集型的应用,可以将计算分解到多个线程中实现。
- 对于I/O密集型应用,可以将I/O操作重叠,让多个线程同时等待不同的I/O操作。
- 但多线程不具备独立性。对于同一进程的多个线程,一个线程崩了,就会导致整个进程出崩溃,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也会随即退出。
- 多线程带来便利的同时也带来了一系列的安全问题,所以在使用多线程的同时还需要兼顾临界资源的保护、线程的同步调度等条件,会对进程性能有一定的影响,而且会增加使用成本。
- 多线程会有缺乏访问控制的问题。进程是访问控制的基本粒度(单位),在一个线程中调用某些系统调用时(例如信号相关的系统调用)会对整个进程造成影响。
注意,后续内容出现的线程id指的并不是LWP,而是指的pthread库封装的线程id。
线程控制
线程创建
Linux中使用 pthread_create 函数用于创建一个新线程。
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
函数说明:
- thread参数,是一个输出型参数,表示创建的新线程id。pthread_t 是线程id的类型。
- attr参数,表示指定新创建线程的属性,如果为null,则表示使用默认属性。
- start_routine参数,是一个函数指针,表示新线程要执行的函数。
- arg参数,是作为start_routine函数的参数传入进去的。
- 返回值,成功返回0,失败返回一个非0的错误码。
特别的,start_routine函数的参数和返回值都是void*类型的,这就说明其实我们可以将需要传入或返回的多种数据放到一个结构体中,这样start_routine函数就可以传入和返回多种类型的数据了。即只要有地址,那么各种类型的数据都可以传,只需要按照特定的格式使用即可。
pthread_create函数虽然叫做创建线程,但其实应该叫创建并运行线程,因为pthread_create在创建线程成功后,新线程紧接着就会被调度。且新线程创建完成后,主线程会照常向后执行,新线程也正常运行,它们之间并不会相互影响。
线程终止
Linux中使用 pthread_exit 函数用于退出一个线程
#include <pthread.h>
void pthread_exit(void *retval);
函数说明:
函数只有一个参数,retval表示要返回的值,可以为null。函数没有返回值。
需要注意的是,线程终止其实可以直接return的,效果和pthread_exit类似。也可以用。但不能用exit函数终止线程,因为exit是直接退出整个进程。
线程等待
Linux中使用 pthread_join 函数用于线程等待
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
函数说明:
- thread参数,表示要等待的线程id。
- retval参数,是一个输出型参数,表示获取等待进程的返回值。可以设为null。
- 返回值,成功返回0,失败返回一个非0的错误码。
我们知道,进程通过需要wait来回收资源,而线程同样也需要等待。如果线程退出没有等待,也会导致诸如进程僵尸的问题,只不过多线程这里很难看到这个现象。而且,一般我们默认main函数所在的线程为主线程,当主线程退出时,默认会退出整个进程,那么自然所有的线程也都会退出。如果main函数的执行流先于其它线程退出,会导致未定义问题。
所以,介于上述种种原因,线程创建之后并不能直接结束,还是很有必要进行线程等待的。其中,pthread_join为阻塞等待。一般我们不考虑非阻塞式的等待,要么阻塞等待,要么不等待。
线程分离
Linux中使用 pthread_detach 函数用于线程分离
#include <pthread.h>
int pthread_detach(pthread_t thread);
函数说明:
- thread参数,表示要分离的线程id。
- 返回值,成功返回0,失败返回一个非0的错误码。
线程分离的作用是,将指定线程标记为分离的线程。当分离的线程终止时,其资源会自动释放回系统,无需通过线程等待回收。而且一旦线程被分离,就不能再使用pthread_join()对其进行等待了,对一个已分离的线程使用pthread_join()等待会导致程序异常。
线程分离就相当于是脱离了主线程的控制,主线程也就不再管他了。但线程分离并不是真正意义上的将线程从当前进程中分离出去,其实只是对分离线程的属性做了修改,将其标为分离的。所以线程分离除就相当于是一个线程独立了,但依旧是属于整个进程执行流中的一员。
线程取消
Linux中使用 pthread_cancel 函数用于取消一个线程
#include <pthread.h>
int pthread_cancel(pthread_t thread);
函数说明:
- thread参数,表示要分离的线程id。
- 返回值,成功返回0,失败返回一个非0的错误码。
线程取消,顾名思义就是取消一个线程。pthread_cancel是一个非阻塞函数,所以调用了该函数后,哪怕子线程还没终止,主线程仍然可以继续往下运行。
但Linux下的线程取消本质上是通过向thread线程发送终止信号来实现的。而我们知道,同一进程下的线程共用一套信号处理方法,所以一个线程取消(终止)了,那么就会导致整个进程退出。所以为了达到只取消一个线程的效果,我们还需要借助 pthread_setcancelstate 函数,设置当前线程对于线程取消的自定义方法。
int pthread_setcancelstate(int state, int *oldstate)
详情参考:线程取消(pthread_cancel) - Cynthia&Sky - 博客园 (cnblogs.com)
其它
pthread_self
除了上述的几个关键函数外,pthread库中常用的还有 pthread_self() 函数,用于获取执行当前线程的线程id。函数声明如下:
#include <pthread.h>
pthread_t pthread_self(void);
主线程问题
一般情况下,我们认为main函数所在的执行流叫做主线程,主线程退出会导致其它的副线程也随之退出,且不受线程分离的影响。
但其实进程中并没有什么主线程的概念,同一个进程中的所有线程之间都是平级关系。即线程都是一样的, 退出了一个不会影响另外一个。之所以main函数执行流退出会导致其它执行流也退出,是因为整个进程在启动时,其入口代码是以类似 exit( main(...) ) 的方式调用main函数的,所以main函数正常执行结束之后,会随之调用 exit() 函数,所以就会导致整个进程直接退出。
想让main函数所在的线程退出不影响整个进程的做法是,在main函数中用 pthread_exit() 函数代替原来的return。按照POSIX标准,如果主线程在子线程终止之前调用了pthread_exit(),子线程是不会退出的。main()中调用了pthread_exit后,会导致主线程提前退出,其后的exit()就无法执行了,所以要到其他线程全部执行完了,整个进程才会退出。
内容参考:多线程情况下,主线程先退出,子线程会被强制退出吗
线程退出没有退出码
之所以线程没有退出码,不处理线程出异常的情况,是因为如果一个线程出异常了,那么整个进程就挂掉了,所以就没有这个必要。
每个线程拥有独立栈空间的理解
线程上下文是由pthread库封装了Linux内核的相关系统调用,即线程上下文是Linux内核维护的,而每一个线程的栈空间则是需要由pthread库提供的。
譬如语言的 stdio 库可以维护用户级缓冲区,所以 pthread 库维护一个线程的栈空间是行得通的。
而pthread库运行时是被加载到共享区的,所以pthread维护的栈本质上是在堆区申请的空间。而虚拟地址空间中的栈是则由主线程来使用的。
线程互斥
互斥的概念
我们知道,同一进程的每个线程都有各自独立的栈空间,而虚拟地址空间中还有其它空间,诸如全局区、静态区等。所以多线程的情况下,全局变量等非栈区的资源其实是被所有线程所共享的。我们把这种多个执行流都可以访问的资源叫做共享资源。特别的,我们把任意时刻只能被一个执行流访问的资源(共享资源)叫做临界资源,例如显示器的本质是stdout,本质上就是一种临界资源。而临界资源本质上是要通过代码访问的,我们便把访问临界资源的代码部分叫做临界区,临界区就是我们将来要保护的区域。而任何时刻,有且只有一个执行流进入临界区,访问临界资源,就叫做互斥,互斥通常对临界资源起保护作用。
那么我们为什么需要互斥呢?这里就需要再引入一个叫做原子性的概念了。原子性是指一个操作在执行过程中被视为不可分割的单一单元,即该操作要么全部完成,要么完全不执行,不存在完成一半的这种中间状态。一般情况下,我们的大多数操作都不是原子的,甚至就连一个简单的 a++ 操作都会分解为读取、加1、写回三个步骤。我们知道,线程调度可能会在任意时刻发送,即线程在执行中可能在任意位置发生中断,所以就有可能发生对临界资源的错误处理。所以我们需要用一种方式对临界资源保护起来,保证临界资源的互斥访问,这种方式就是互斥锁。
互斥锁又叫互斥量,通过提供一种排他性访问控制手段,确保在任何时刻,只有一个线程能够对受保护的临界区进行操作,而其他试图同时访问该区域的线程则会被阻塞,直至持有锁的线程完成其操作并释放锁。
互斥锁的使用
互斥锁的初始化与释放
Linux下互斥锁的初始化方式如下:
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
可以看到,有两种初始化方式,可以用 PTHREAD_MUTEX_INITIALIZER 宏直接定义并初始化。如果想要实现更为复杂的功能可以用 pthread_mutex_init 函数。其中,pthread_mutex_t 是互斥锁的结构体类型。
pthread_mutex_init 的函数说明如下:
- pthread_mutex_t是互斥锁的id。
- mutex参数,指向要初始化的锁。
- attr参数,表示初始化锁设置的属性,为null就表示使用默认属性。
- 返回值,成功返回0,失败返回一个非0的错误码。
相应的,互斥锁的释放函数为 pthread_mutex_destroy,其只有一个参数,指向要释放的互斥锁。返回值也是成功返回0,失败返回一个非0的错误码。而pthread_mutex_destroy函数的本质其实就是对mutex互斥锁进行去初始化操作,使传入的mutex成为一个无效值。
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥锁在初始化时,如果不需要设置属性,可以直接用宏的方式初始化,其效果就等同于pthread_mutex_init 函数的attr参数设为null。但一般习惯全局和静态的互斥锁使用宏初始化,局部的或类内的用 pthread_mutex_init 函数进行初始化。
申请锁与释放锁
互斥锁初始化完成之后就可以使用了,我们通过 pthread_mutex_lock 函数进行加锁操作(申请锁)
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
函数说明:
- mutex参数,表示对 mutex 指向的互斥锁进行加锁或者叫申请锁。
- 返回值,成功返回0,失败返回一个非0的错误码。
我们用 pthread_mutex_lock 函数对后续内容进行加锁,当要出临界区时,我们就需要对锁资源进行释放,而不是一直持有锁。所以对应的,我们用 pthread_mutex_unlock 函数来释放锁。
#include <pthread.h>
int pthread_mutex_unlock(pthread_mutex_t *mutex);
加锁操作的过程大致可以概括为:当线程进行加锁操作时,pthread_mutex_lock函数首先会查看当前mutex锁的状态。如果有其它线程正在使用该锁,那么当前进程就会在pthread_mutex_lock函数处阻塞住。如果该锁没有线程在使用,那么就申请锁并返回。也就是说,任何一个时刻只允许一个线程申请锁成功,而失败的线程就会在mutex_lock处阻塞住。
在申请锁处阻塞本质上是对锁这种资源的等待,所以锁其实也可以看作是一种资源。而多个执行流都需要使用锁,且同一时刻只允许一个线程获得锁,所以更确切的说,锁其实是一种临界资源。所以加锁操作其实又叫申请锁,互斥锁又叫互斥量。
而在加锁期间的代码处也是有可能会被发生线程切换的。但由于这段代码是在加锁期间的,所以只要我们还没解锁,那么其他线程就无法进入到加锁期间的代码,会在申请锁的代码处阻塞住。也就是说,锁保证了加锁期间的整个临界区的原子性。
除了 pthread_mutex_lock 阻塞式申请锁之外,其实还有 pthread_mutex_trylock 非阻塞式申请锁,只不过我们一般使用的都是阻塞式申请锁。
内容补充
- 加锁和解锁之间的区域就是真正被锁住的部分。
- 由于加锁和解锁的操作本身就是原子的,所以加锁和解锁操作本身其实是安全的。
- 加锁虽然能够保证临界资源的安全性,但并不能一味地使用锁。使用锁必然会导致效率降低,所以要尽可能给少的代码加锁,一般只给临界区加锁。
- 加锁的一般原则:哪个线程加锁,就由哪个线程解锁。
- 多个执行流访问同一个临界资源需要用同一把锁,否则将达不到互斥的效果。即一个临界资源对应一把锁,不受线程数量的影响。
锁的本质
所谓的锁,在计算机里本质上就是一块内存空间。当这个空间被赋值为1的时候表示加锁了,被赋值为0的时候表示解锁了,仅此而已。多个线程抢一个锁,就是抢着要把这块内存赋值为1。在一个多核环境里,内存空间是共享的。每个核上各跑一个线程,那如何保证一次只有一个线程成功抢到锁呢?这就需要硬件的支持了。
内容参考:互斥锁(mutex)的底层原理是什么?- 知乎 (zhihu.com)
线程同步
线程同步的概念
由于不同环境的线程调度方式不同,所以在多执行流运行的过程中,可能会出现某个或某些线程由于无法获得必要的资源(如CPU时间、锁、同步对象等)而长期处于等待状态,无法执行或者长时间无法完成任务的现象。这种现象叫做线程的饥饿问题。例如对于临界区加锁的情况下,可能就会出现长期只有一个线程抢占到锁资源,那么其它线程自然就线程饥饿了。而且,如果只有互斥锁,那么很有就会出现频繁申请释放锁,不断抢占锁资源的情况,会让效率降低。
而为了避免线程饥饿的问题和频繁申请释放锁的低效情况,我们就需要有线程同步。同步就是协同步调,即让多个线程按照规定的先后顺序执行。
也就是说,只有互斥没有同步会导致线程饥饿等一些问题。所以多线程访问临界资源不光要让线程之间互斥,保证安全问题。还要让线程之间保持同步,按照一定的步调执行,保证多线程执行的效率问题。
条件变量的概念
线程同步是通过条件变量来做到的,条件变量能让线程不做无效的锁申请,使得多线程运行的过程具有顺序性。条件变量可以理解为是一个锁资源的控制者,可以让多个执行流在申请锁时按照一定的顺序在条件变量处排队,这样条件变量就能让多个线程按照一定的先后顺序拿到锁资源,也就使得多个执行流能够按照一定的先后顺序访问临界区。
而条件变量其实是提供了一种唤醒机制和等待队列的,可以实现线程的阻塞和对等待队列中阻塞线程的唤醒操作。而条件变量相当于使锁执行同步机制的一个控制条件,所以条件变量是需要互斥锁配合使用的。
条件变量的使用
条件接口的使用和互斥锁的接口使用方法类似,大致如下。
条件变量的初始化与释放
#include <pthread.h>
// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 释放
int pthread_cond_destroy(pthread_cond_t *cond);
接口说明:
- pthread_cond_t是条件变量的id。
- pthread_cond_init是初始化条件变量的函数,参数cond表示指向要初始化的条件变量,参数attr表示设置的条件变量属性,可以为null,如果为null则表示使用默认属性。如果attr为null,也可以直接用PTHREAD_COND_INITIALIZER 宏来初始化。
- pthread_cond_destroy函数用于销毁条件变量,本质上是对cond指向的条件变量去初始化。
条件变量的等待与唤醒
条件变量的等待与唤醒操作有三个常用的函数:
// 在指定条件变量下等待
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
// 唤醒指定条件变量下的一个线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒指定条件变量下的全部线程
int pthread_cond_broadcast(pthread_cond_t *cond);
- pthread_cond_wait函数,在指定的条件变量处阻塞住,加入到对应条件变量的队列下排队等待,cond表示指定的条件变量,mutex表示对应的互斥锁。大致过程是:线程在pthread_cond_wait处阻塞时会自动释放对应的锁,pthread_cond_wait返回之后,线程结束阻塞并重新申请锁。
- 所以当线程在pthread_cond_wait处阻塞时,锁资源处于空闲状态,其它进程就可以申请锁并持有锁了。条件变量等待之后,需要唤醒才能再继续,否则就会一直阻塞。
- pthread_cond_signal函数,使指定的条件变量成立,并唤醒一个在指定条件变量处阻塞的线程。cond参数表示指定的条件变量。先阻塞的先入等待队列,所以先阻塞的先唤醒。
- pthread_cond_broadcast函数,也是使指定的条件变量成立,并唤醒所有等待这个条件变量的线程。其中,批量唤醒是无法保证唤醒线程的执行顺序的。
信号量
信号量的概念
信号量的本质是一个计数器,用于解决多个执行流对共享资源的访问控制问题。例如某个资源最多只允许2个进程同时访问,那么就将对应的信号量设为2。信号量的核心操作在于其PV操作:
- P 操作:先将信号量的值减 1,表示申请占用一个资源。如果减一之后的信号量小于 0,则表示已经没有可用资源了,那么执行流会在 P 操作处被阻塞住。例如在P操作之后,如果信号量的值为2,表示还有 2 个资源可以使用;如果信号量的值为-2,则表示有两个进程正在等待使用这个资源。
- V 操作:将信号量值加 1,表示释放一个资源。若减一之后的信号量小于等于 0,则表示有执行流正在等待该资源,此时需要唤醒一个在对应信号量处阻塞的执行流。
P 操作和 V 操作必须成对出现。缺少 P 操作就不能保证对资源的互斥访问,缺少 V 操作就会导致资源一直得不到释放,阻塞住的执行流永远得不到唤醒。信号量的本质就是一个计数器,申请信号本质上就是预定资源,且PV操作是原子的。
互斥锁与信号量的主要区别如下:
- 粒度:互斥锁是细粒度的同步工具,主要针对单一资源的互斥访问;信号量则是粗粒度的,可以控制多个同类资源的并发访问或多个线程的复杂同步关系。
- 计数能力:互斥锁不具备计数能力,只能表示锁定或解锁状态;信号量具有计数能力,其值表示可用资源的数量。
- 适用场景:互斥锁适用于简单的互斥需求,确保同一时刻只有一个线程访问特定资源;信号量适用于更复杂的同步场景,如控制资源的并发访问量、实现多线程的协作流程。
信号量接口的使用
需要注意的是,POSIX标准的信号量是的头文件是semaphore.h,而不是pthread.h了。
信号量的初始化与释放
#include <semaphore.h>
// 信号量的初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 信号量的释放(去初始化)
int sem_destroy(sem_t *sem);
接口说明:
- sem_t是信号量的id。
- sem_init和sem_destroy中的sem参数都表示指向目标信号量的指针,只不过一个是用于初始化,一个是用于去初始化。
- pshared参数,表示信号量的共享方式,如果为0,则表示信号量在进程的线程之间共享。如果pShared为非零,则信号量在进程之间共享,并且应该位于共享内存区中。
- value参数,表示信号量的初始化值,后续可以通过PV操作对其修改。
信号量的PV操作
#include <semaphore.h>
// P操作(递减信号量,减到0就阻塞)
int sem_wait(sem_t *sem);
// V操作(递增信号量,大于0就唤醒)
int sem_post(sem_t *sem);
接口说明:
- P操作和V操作都只有一个参数sem,表示指向目标信号量的指针。
- sem_wait函数(P操作):减少(锁定)sem指向的信号量。如果当前信号量的值大于0,则正常递减。如果当前信号量当前的值等于0,则会阻塞住,知道被唤醒。
- sem_post函数(V操作):递增(解锁)sem指向的信号量。如果递增之后的信号量大于0,则尝试唤醒目标信号量处阻塞住的执行流。
常见锁的概念
下述内容部分参考:什么是悲观锁、乐观锁? | 小林coding (xiaolincoding.com)
互斥锁与自旋锁
概念认识
最底层的两种锁就是「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。
加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。而互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?就是会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则就使用互斥锁。
自旋锁
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
如果使用的是自旋锁,当发生多线程竞争锁的情况时,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以想象成是一个while循环实现。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意的是,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
其中,互斥锁的接口我们已经在前面认识了,下面是Linux下自旋锁的相关接口,使用方法和互斥锁的类似,只是原理有所不同,这里就不再过多赘述了。
#include <pthread.h>
// 自旋锁初始化
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
// 自旋锁的销毁(去初始化)
int pthread_spin_destroy(pthread_spinlock_t *lock);
// 自旋锁上锁
int pthread_spin_lock(pthread_spinlock_t *lock);
// 自旋锁解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
互斥锁与自旋锁对比
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。
乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
乐观锁做事比较乐观,它假定冲突的概率很低,每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
其它
公平锁与非公平锁
- 公平锁:公平锁在并发环境中获取锁时会查看锁维护的等待队列,如果队列为空,或者当前线程是等待队列的第一个,就占有锁,否则加入等待队列,按照FIFO等待获取锁。
- 非公平锁:非公平锁直接尝试占有锁,如果尝试失败,就在采用类似公平锁的方式获取锁。
独占锁(写锁)与共享锁(读锁)
- 独占锁:独占锁锁一次只能被一个线程持有。
- 共享锁:共享锁可以被多个线程共同持有。
经典同步问题
哲学家就餐问题与死锁
哲学家就餐问题的概述如下。假设有一张圆桌,周围坐着5位哲学家,每位哲学家面前摆放着一盘食物和两把叉子,每位哲学家只能使用自己左右两侧的叉子来用餐。哲学家只有思考和进餐两种状态,他们不断地在这两个状态之间切换。哲学家必须同时拿到左右两只叉子才能进餐。吃完一口他们就会继续放下两只叉子回到思考状态。如图所示:
问题的漏洞在于,当所有哲学家都拿起一边的叉子后,就会出现循环等待的情况。例如当所有哲学家都拿起左侧的叉子时,他们下一步的动作都是去拿右侧的叉子,那么就会陷入无限等待右侧叉子的情况。
将哲学家抽象成线程,将叉子抽象为互斥锁,当哲学家拿叉子死循环时,就是死锁的场景。
理论上讲,死锁有四个必要条件:
- 互斥条件:一个共享资源每次只能被一个执行流使用。
- 持有并等待条件:一个执行流因请求资源而阻塞(等待)时,对已获得的资源保持不放。
- 不可剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
所以要想避开死锁问题,就需要从上述四个条件入手,想办法破环其中一个条件就不会形成死锁了。
解决哲学家就餐问题的常见方法有:
- 最多只允许4个哲学家同时拿筷子,保证至少有一人能够进餐。
- 仅当左、右两根筷子均可用时,才允许他拿起筷子。
- 奇数号哲学家先拿左边的筷子,偶数号先拿右边的筷子。
其原理都是破坏了循环等待条件,对应的还有死锁检测算法,也是破环了循环等待条件,通过构建线程与资源之间的等待图 (WFG),通过拓扑排序判断图中是否有环,进而判断是否会构成循环等待条件。
还有一个做银行家算法,用一句话概括就是:当一个线程要申请使用资源的时候,银行家算法通过先“尝试”分配给该进程资源,然后通过安全性算法判断分配后的系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待。大致流程图如下:
但其实,解决死锁的最好办法就是不使用锁,即无锁编程。但多执行流下的无锁编程不是那么简单的,感兴趣的可以尝试阅读:无锁队列的实现 | 酷 壳 - CoolShell
生产者-消费者模型
生产者-消费者模型由两个角色——生产者和消费者,一个交易场所——缓冲区(一般是内存空间)构成。生产者生产数据后,不直接交给消费者,而是放到缓冲区中。而消费者则每次从缓冲区中拿数据。任何时刻,只能有一个生产者或者消费者可以访问缓冲区。
所以,生产者和生产者、消费者和消费者、生产者与消费者之间都是互斥的关系。
而且,如果缓冲区为空,则消费者必须等待生产者生产数据,如果缓冲区满了,则生产者必须等待消费者从缓冲区中取出数据。所以生产者和消费者之间不仅是互斥的,而且还需要同步。
只需要对缓冲区加锁就可以保证同一时刻只有一个生产者或者消费者访问缓冲区了,而至于生产者和消费者,则可以用两个条件变量控制,一个用于缓冲区为空时让消费者阻塞,一个用于缓冲区满时让生产者阻塞。
生产消费模型通过一个缓冲区将生产和消费两个操作进行解耦,支持多生产多消费。这种异步处理方式提高了系统的整体吞吐量和响应速度。尤其适用于处理速度不一致、工作负载波动较大的场景。
读写者模型与读写锁
读写者模型是一种读者多写者少,且读写频次比较高的场景。与生产消费者模型类似,读写者模型由两个角色——生产者和消费者,以及一个场所——缓冲区构成。但不同的是,生产者是从缓冲区中拿数据,拿完之后缓冲区中的数据也就不在了,而读者是从缓冲区中读数据,拿到的只是数据的拷贝,读完之后缓冲区中的数据还在。
读写者模型的关系描述:
- 读者与读者:没有互斥与同步的关系,只有并行或并发的关系。
- 写者与写者:互斥的关系,同一时刻只允许一个写者向缓冲区中写入。
- 读者与写者:互斥与同步的关系。没有写者时读者才能读,没有读者时写者才能写。
所以基于读写者模型,便产生了读写锁。读写锁是一种特殊的锁机制,它允许多个读线程同时访问资源,但同一时刻只允许一个写线程进行写操作。
其中,Linux下有具体的读写锁接口:
#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_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 读加锁
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_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref共有3种选择:
PTHREAD_RWLOCK_PREFER_READER_NP (默认缺省)读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
内容补充
线程的局部存储
每个线程都有各自的栈空间,但其它的全局变量、静态变量等是被所有进程所共享的。
而如果在变量之前加一个 __thread 就表示线程的局部存储,即这个全局变量或其他共享资源就不再同步了。原理大致为将这个变量在自己的线程局部存储区域私有一份。这个工作发生在编译期。
要注意,__thread 编译选项并不支持c++的各种容器。
volitale关键字
volatile是一个C语言的类型修饰符。它被设计用来修饰被不同线程访问和修改的变量。如果没有volatile,基本上会导致这样的结果:要么无法编写多线程程序,要么编译器失去大量优化的机会。
volatile用于提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,告诉编译器对该变量不做优化,都会直接从变量内存地址中读取数据,而不是去CPU寄存器中读取,从而可以提供对特殊地址的稳定访问。
如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
所以在多线程的情况下,如果进行了编译优化,就需要对共享变量加volatile修饰。
其它概念
可重入与不可重入
- 概念:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
- 常见不可重入的情况:
- 调用了malloc/free的函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库的函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构
- 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
线程安全与可重入
- 线程安全的概念:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 也就是说,如果一个函数不可重入,那么在多线程执行时,可能会出现线程安全问题。如果一个函数可被重入的,那么就一定不会出现线程安全问题。
- 可重入与线程安全的联系:
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题,如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入函数是线程安全函数的一种。 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 可重入与线程安全区别:
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
- 最后总结就四个结论:
- 线程安全描述的是并发的问题。
- 可重入描述的是函数特点的问题。
- 不可重入函数在多线程访问时,可能会出现线程安全问题。
- 可重入函数在多线程访问时,不会有线程安全问题。
互斥、同步、异步、竞争条件
- 互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即互斥是无序的。
- 同步:就是协同步调,让多个线程按照规定的先后顺序执行。通常需要建立在互斥的基础上。
- 异步:异步与同步相对,异步就是多执行流之间彼此独立,互不干涉。
- 竞争条件:是指多执行流对同一共享资源进行访问或修改时,由于它们的执行顺序受到各自调度和执行速度不确定性的影响,导致最终结果取决于这些实体执行时序的特定情况,而非遵循预期的逻辑。
并发、并行与串行
- 并发:并发是指两个或多个事件在同一时间间隔内发生。虽然看起来是同时进行的,但实际上是通过快速的切换来实现的。
- 并行:指的是多个任务同时进行,可以利用多个处理器核心或者多个CPU来实现。
- 串行:指的是多个任务按照一定的顺序依次执行,一个任务必须在前一个任务完成之后才能开始执行。