一、进程线程间通信的相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。确切的说,临界资源在同一时刻只能被一个执行流访问。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:通过互斥操作能够保证在任何时刻,有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
二、互斥锁
2.1 竞态条件
- 大部分情况,线程使用的数据都是局部变量,变量存储在线程栈空间内。这种情况,变量归属单个线程,其他线程无法访问这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。比如,全局数据、堆空间数据。
- 多个线程并发的操作共享变量,会带来数据竞争,冲突以及数据不一致等竞态条件问题。
竞态条件:
- 竞态条件(Race Condition)是指多个线程或进程同时访问共享资源,并且对资源的访问顺序不确定,导致最终结果的正确性依赖于线程执行的具体时序。竞态条件可能导致不可预测的结果,破坏程序的正确性和一致性。
- 竞态条件通常发生在多个线程或进程同时对共享资源进行读写操作时,其中至少一个是写操作。当多个线程或进程同时读写共享资源时,由于执行顺序的不确定性,可能会导致数据的不一致性、丢失、覆盖等问题。
测试程序:
int tickets = 100; //共有100张票void *ThreadRoutine(void *name)
{while (1){if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;}else{break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}int main()
{srand((unsigned)time(nullptr));pthread_t tid1, tid2, tid3, tid4;pthread_create(&tid1, nullptr, ThreadRoutine, (void *)"child thread 1");pthread_create(&tid2, nullptr, ThreadRoutine, (void *)"child thread 2");pthread_create(&tid3, nullptr, ThreadRoutine, (void *)"child thread 3");pthread_create(&tid4, nullptr, ThreadRoutine, (void *)"child thread 4");pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);pthread_join(tid3, nullptr);pthread_join(tid4, nullptr);return 0;
}
运行结果:
- 同一编号的票被多个线程售出
- 某些线程售出了负数编号的票
该程序存在竞态条件问题,即公共变量tickets
被多执行流同时访问和修改。
提示:除了多线程进程外,信号处理函数也是异步执行的(多执行流执行),同样存在竞态条件问题。
并发运行问题
例如:tickets > 0
和--tickets
操作并不是原子性操作,而是对应三条汇编指令:
- 将数据从内存加载到寄存器(当前线程的上下文中)
- 进行逻辑运算或算数运算
- 将数据写回内存
在这三条步骤的其中任何一步,该线程都有可能被切换,切换前线程上下文会被保存。其他线程在执行时也对tickets进行了访问和修改。当原线程再次被CPU调度执行时,恢复上下文数据,此时的寄存器与内存就会发生数据不一致的错误。
并行运行问题
多核CPU允许多线程并行(同时)运行。在ThreadRoutine
函数中,由于没有对访问tickets
的操作进行互斥,可能会导致多个线程同时读取和修改tickets
变量,从而产生不可预测的结果。
例如:当多个线程同时执行if (tickets > 0)
语句时,可能会出现以下情况:
- 线程A和线程B同时读取
tickets
的值为1。 - 线程A先执行
--tickets
操作,将tickets
的值减为0。 - 线程B再执行
--tickets
操作,将tickets
的值减为-1。
这样,就会出现某些线程售出了负数编号的票。
2.2 互斥锁的基本用法
为了解决竞态条件问题,可以使用互斥锁(Mutex)来保护对tickets
变量的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区(对tickets
变量的访问),从而避免数据竞争的发生。
下面是互斥锁的基本使用方法:
- 定义互斥锁变量:在使用互斥锁之前,需要先定义一个互斥锁变量。可以使用
pthread_mutex_t
类型来声明互斥锁变量,例如:pthread_mutex_t mutex;
- 初始化互斥锁:在使用互斥锁之前,需要对互斥锁进行初始化。
- 静态初始化:在定义互斥锁变量时,使用
PTHREAD_MUTEX_INITIALIZER
宏进行初始化。 - 动态初始化:可以使用
pthread_mutex_init
函数来初始化互斥锁,例如:pthread_mutex_init(&mutex, NULL);
。第一个参数是要初始化的互斥锁变量,第二个参数是互斥锁的属性,通常使用NULL
表示使用默认属性;
- 静态初始化:在定义互斥锁变量时,使用
- 加锁:在访问共享资源之前,需要先加锁。可以使用
pthread_mutex_lock
函数来加锁,例如:pthread_mutex_lock(&mutex);
。如果互斥锁已经被其他线程锁定,那么当前线程会被阻塞,直到互斥锁被解锁。 - 访问共享资源:在互斥锁被锁定的情况下,可以安全地串行访问共享资源。
- 解锁:在访问共享资源完成后,需要解锁互斥锁,以便其他线程可以继续访问共享资源。可以使用
pthread_mutex_unlock
函数来解锁,例如:pthread_mutex_unlock(&mutex);
。 - 销毁互斥锁:
- 不再需要使用互斥锁时,需要将其销毁。可以使用
pthread_mutex_destroy
函数来销毁互斥锁,例如:pthread_mutex_destroy(&mutex);
。 - 静态初始化的互斥锁在程序结束时会自动被系统回收,无需手动销毁。
- 不要销毁一个已经加锁的互斥量
- 对于已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 不再需要使用互斥锁时,需要将其销毁。可以使用
我们将上面的售票程序加入互斥锁:
int tickets = 100; //临界资源
// 定义一个全局的互斥锁变量,并利用宏进行初始化(静态初始化)
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void *ThreadRoutine(void *name)
{while (1){// 在访问共享资源之前,需要先加锁。pthread_mutex_lock(&mtx);//临界区if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);}else{// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);break;}// 在此处解锁?不行,如果线程执行break,就不会解锁互斥锁。其他线程会被一直阻塞。usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}
运行结果:
需要注意的几点:
-
在访问共享资源完成后,需要解锁互斥锁。否则,其他线程会被一直阻塞。需要特别注意break, goto等跳转语句跳过解锁函数。
-
被互斥锁锁定的临界区只能串行执行(互斥访问),虽然保证了多执行流访问临界资源的安全性,但是会在一定程度上降低程序的效率。
-
尽量保证被互斥锁锁定的代码都是访问临界资源的代码,不要将其他无关的操作也放入临界区中。因为相比并发或并行执行,临界区串行执行的效率较低。
再次改进上面的代码:
#define THREAD_NUM 5
int tickets = 100;//声明一个ThreadData类,使线程入口函数的参数更多样化。
class ThreadData
{
public:string _tname; //线程名pthread_mutex_t *_pmtx; //互斥锁变量的地址ThreadData(const string &tname, pthread_mutex_t *pmtx): _tname(tname),_pmtx(pmtx){};
};void *ThreadRoutine(void *arg)
{ThreadData *td = (ThreadData *)arg;while (1){// 在访问临界资源前进行加锁pthread_mutex_lock(td->_pmtx);if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", td->_tname.c_str(), tickets);--tickets;// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);}else{// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}delete td; // 释放各自的ThreadData结构空间return nullptr;
}int main()
{srand((unsigned)time(nullptr));// 在主线程栈区创建互斥锁变量pthread_mutex_t mtx;// 调用pthread_mutex_init初始化互斥锁(动态初始化)pthread_mutex_init(&mtx, nullptr);// 循环创建子线程pthread_t tid[THREAD_NUM];for (int i = 0; i < THREAD_NUM; ++i){string tmp = "child thread ";tmp += to_string(i + 1);ThreadData *td = new ThreadData(tmp, &mtx);pthread_create(tid + i, nullptr, ThreadRoutine, td); //传入ThreadData对象的指针}// 循环等待子线程for (int i = 0; i < THREAD_NUM; ++i){pthread_join(tid[i], nullptr);}// 在不再需要使用互斥锁时,需要将其销毁。(动态初始化的互斥锁需要进行销毁,而静态初始化不需要)pthread_mutex_destroy(&mtx);return 0;
}
新的问题:
-
加锁了之后,线程在执行临界区代码时,是否会被切换,会有问题吗?
会被切换,但不会有问题!虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,而它是无法申请成功的。所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性! -
对于访问临界资源的线程而言,临界区代码要么全部执行成功,要么全部不执行,访问临界资源的操作不可被中断(不能同时执行其他线程的临界区代码),这就是原子性的体现。
-
要访问临界资源,每一个线程都必须先申请锁,而锁本身就是一种共享资源,那么谁来保证锁的安全呢?
所以,为了保证锁的安全,申请和释放锁,必须是原子的!
2.3 互斥锁的原理
- 在汇编层面,一条汇编语句要么已经执行完,要么就还没有执行,是原子性的。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange汇编指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台(并行),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
- CPU的寄存器数据,本质就是当前执行流的上下文。寄存器的存储空间被所有执行流共享,但是寄存器的内容是被每一个执行流私有的。所以在切换线程时,要将当前线程的寄存器数据保存到其PCB中,并恢复下一个线程的寄存器数据。
以下是加锁的核心汇编伪代码:
lock:movb $0, %al // 将数值0,move到al寄存器中xchgb %al, mutex //交换al寄存器与mutex变量(内存)的数据if(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock; //跳转到lock标签,再次申请锁
- 我们可以将互斥锁变量mutex理解成一个整形变量,值为1表示互斥锁未被线程持有;值为0,表示互斥锁已经被其他线程锁定。创建互斥锁变量并进行初始化后,其默认值为1。
- 由于exchange汇编指令是原子的,所以不管线程如何切换,只有一个线程能够将mutex(内存)中的1值交换到自己的寄存器当中,即该线程的上下文中。而线程上下文是线程的私有数据,实现了公有到私有的转换。同时寄存器当中的0值被交换到了mutex中,其他线程再进行交换也只能交换到0。
- 在进行if判断时,交换到1值的线程执行return 0,可以安全地进入临界区访问临界资源;而交换到0值的线程阻塞等待,直到互斥锁被解锁,这些线程才会被唤醒,然后再次尝试申请锁。
以下是解锁的核心汇编伪代码:
unlock:movb $1, mutex //将数值1,move到mutex变量(内存)唤醒等待mutex的线程;return 0;
- 当持有锁的线程访问完临界资源后,会将mutex变量重新置为1,即解锁互斥锁。
- 同时,应该唤醒等待互斥锁解锁的线程,让他们再次竞争申请锁。
回答之前的问题:
-
谁来保证锁的安全呢?
为了保证锁的安全,申请和释放锁,必须是原子的!在设计加锁时,通过一条原子性的exchange指令,保证了加锁和解锁的原子性。
-
加锁了之后,线程在临界区中,是否会切换,会有问题吗?
线程在临界区中也可能会被切换,但他是持有锁被切换的,所谓持有锁切换是指互斥锁的1值保存在当前线程的上下文,被当前线程私有。而其他线程即使被CPU调度执行,也无法抢占互斥锁,也就无法访问临界区代码。所以不会有任何问题。
三、可重入函数和线程安全
- 可重入函数:同一个函数被多个执行流同时进入,就叫重入。如果该函数在被重入执行的过程中不会出现任何错误,则被称为可重入函数。反之就是不可重入函数。
- 线程安全:多个线程并发执行时,在没有锁保护的情况下访问了共享资源(如全局或静态变量,堆区数据等),会出现数据竞争从而导致数据冲突,数据不一致等线程安全问题。
3.1 线程安全的情况
- 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 使用互斥锁(Mutex)来保护对共享资源的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区,从而避免数据竞争的发生。
- 每个线程对共享资源只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 不调用线程不安全的函数
3.2 可重入函数的情况
- 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
- 使用互斥锁(Mutex)来保护对共享资源的访问。如全局、静态变量或其他共享资源。
- 不调用不可重入函数
常见不可重入的情况
- 调用了malloc/free函数,因为Linux内核是用全局链表和全局红黑树结构来组织和管理堆空间的。(请看提示)
- 调用了标准I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用了全局数据结构。
提示:
- 关于Linux内核中的堆区管理,请阅读:【多线程】线程的概念 {Linux内核中的堆区管理;虚拟地址到物理地址的转换,页,页框,页表,MMU内存管理单元;Linux线程概念,轻量级进程;线程共享进程的资源;线程的优缺点;线程的用途}-CSDN博客
- 关于多执行流调用不可重入函数插入链表节点,请阅读:【信号】信号处理 {信号处理的时机;内核态和用户态;信号捕捉的原理;信号处理函数:signal, sigaction;可重入函数;volatile关键字;SIGCHLD信号}-CSDN博客
3.3 区别和联系
联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的。但如果这个重入函数加锁还未释放则会产生死锁,因此是不可重入的。
四、死锁
死锁(Deadlock)是指在并发系统中,两个或多个进程(或线程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,进程无法前进,也无法释放资源,导致系统无法正常运行。
死锁通常发生在多个进程(或线程)同时竞争有限的资源时,每个进程都在等待其他进程释放资源,而自己又无法释放已经占有的资源。
特殊情况:一个执行流,一把互斥锁也可能导致死锁,即加锁后,不解锁,再次申请锁。
死锁的发生需要满足以下四个条件,也被称为死锁的必要条件:
-
互斥条件:一个资源每次只能被一个执行流使用(不加锁自然就不会产生死锁)。
-
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
-
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
-
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,使得每个执行流都在等待下一个执行流所占有的资源。
当这四个条件同时满足时,就可能发生死锁。一旦发生死锁,系统将无法自动解除死锁状态,需要通过人工干预来解决。
为了避免死锁的发生,可以采取以下策略:
-
破坏互斥条件:例如,允许多个进程(或线程)同时访问某些资源。
-
破坏请求与保持条件:例如,要求进程(或线程)在执行之前一次性获取所有需要的资源,否则在等待资源时释放已经占有的资源。
-
破坏不可剥夺条件:例如,允许系统强制剥夺某些进程(或线程)的资源。
-
破坏循环等待条件:例如,通过对资源进行排序,按照固定的顺序申请资源,避免交叉申请,循环等待。(T1,T2都先申请R1再申请R2)
-
其他方法:精简临界区代码,缩短持有锁的时间;合并临界区,资源一次性分配(一把锁);
死锁是并发系统中的一个重要问题,对系统的性能和可靠性有很大影响。因此,在设计和实现并发系统时,需要合理地分配和管理资源,以避免死锁的发生。