文章目录:
Linux线程互斥
进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两种态,要么完成,要么未完成。
如何理解临界区和临界资源❓
在多线程编程中,临界资源是指被多个线程共享的资源,例如共享的变量、共享的数据结构等。临界区指的是访问临界资源的代码段,也就是对临界资源进行操作的代码段。
示例:下列代码中的主线程创建了一个新线程,有一个全局计数器 count ,在新线程中每秒对计数器进行加1,主线程每隔一秒打印 count 的值。在这个例子中,count 就是临界资源,它被多个执行流共享;主线程中的 cout 和新线程中的 count++ 就是临界区,因为它们都对临界资源 count 进行了访问。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
int count = 0;
void *startRoutine(void *args)
{
while (true)
{
count++;
sleep(1);
}
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, startRoutine, nullptr);
while (true)
{
cout << "count = " << count << endl;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}
如何理解互斥和原子性❓
在多线程环境下,多个执行流同时对临界资源进行操作可能会导致数据不一致问题。通过使用互斥(Mutual Exclusion)机制,可以保证同一时间只有一个线程能够进入临界区,从而避免了数据不一致问题。
原子性(Atomicity)是指一个操作是不可中断的,要么全部完成,要么完全不执行。在多线程环境下,原子性操作可以保证多个线程对共享变量的操作是原子的,即不会被其它的线程中断。这样可以避免竞争条件和数据不一致问题。
示例:以下代码实现了一个票数计数器,使用线程模拟多个用户同时抢票的场景。
// 票数计票器 - 临界资源,可能因为共同访问,造成数据不一致的问题
int tickets = 10000;
void *getTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
if (tickets > 0)
{
usleep(1000);
cout << name << " 抢到了票,票的编号:" << tickets << endl;
tickets--;
}
else
{
cout << "票已经售完......" << endl;
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}
然而,这段代码中存在一个临界资源问题,可能会导致数据不一致的问题。即 tickets 变量在所有线程之间共享,并且没有对它进行同步保护,可能会导致数据不一致的问题。如下运行结果所示:
对上述代码运行结果进行测试,发现票数减到了负数的情况,出现这种情况的原因:
if
语句判断条件为真以后,代码可以并发的切换到其它线程。usleep
用于模拟业务执行的过程,在这个业务执行的过程中,可能会有很多个线程进入该代码段。tickets--
操作本身就不是一个原子操作。
tickets--
并不是一个原子操作,实际上需要执行以下三个步骤:
1️⃣ load :将变量 tickets 从内存中加载到寄存器中。
2️⃣ update :更新寄存器里面的值,执行 -1 操作。
3️⃣ store :将新值,从寄存器写回共享变量 tickets 的内存地址中。
取出 tickets-- 部分的汇编代码如下:
若有多个线程同时对 tickets 变量进行更改,当执行 tickets--
时,需要三个步骤才能完成。因此,当线程A正在执行 tickets-- 的第一个步骤,将内存中的值 tickets 读取到寄存器中时就被 CPU 切走了。假设此时 threadA 读取到的值为 10000 ,threadA 被切走时,tickets 的值将被从寄存器读取下来保存在 thread A 的上下文中,然后 thread A 被挂起。
CPU 将 threadA 切走之后,threadB 被调度。因为 thread A 只进行第一步操作时就被切走了,因此 threadB 看到的内存中的 tickets 的值还是 10000。若 threadB 的优先级较高,执行了较长的时间,直接将 tickets 的值减至 1000 并写入内存,然后被切走。
threadB 被切走之后,threadA 继续被 CPU 调度,然后 threadA 接着上次执行的地方,将上下文中的数据写入寄存器之后,对 threadA 继续进行 --
的第2步和第3步,然后将 9999 写入内存中。
在上面的示例中,threadA 抢到了1张票,threadB 抢到了9000张票,可是最终余票还有9999张,出现了数据不一致的问题。
虽然 tickets--
只是一行代码,但实际上被编译器解析下来之后需要执行三个指令。即对一个变量进行 --
操作并不是原子的。 (需要注意的是,不同的编译器对于 --
操作的实现方式是不同的。有些语言和编译器可能会提供原子操作的支持。)
互斥量mutex
- 在多线程编程中,大部分情况下,线程使用的数据都是局部变量,这些变量的地址空间在线程栈空间内,每个线程都有自己独立的变量副本,其它线程无法获取这些变量。
- 在某些情况下,很多变量都需要在线程间共享,这样的变量称为共享变量。可以通过数据的共享来完成线程之间的交互和通信。
- 多个线程并发地操作共享变量可能会带来一些问题,包括竞争条件、数据不一致和死锁等问题。
要解决上述问题,需要做到以下三点:
- 互斥行为:当一个线程进入临界区执行共享变量的操作时,其它线程不允许进入该临界区。
- 互斥锁的唯一性:如果有多个线程同时要求进入临界区,而且临界区中没有其它线程正在执行,那么只能允许一个线程进入该临界区。
- 非阻塞行为:如果一个线程不在临界区中执行,那么该线程不能阻止其它线程进入临界区。
要做到以上三点,实际上需要一把锁。Linux上提供的这把锁就叫做互斥量。
互斥量的接口
初始化互斥量
初始化互斥量有两种方法
- 静态初始化:在定义互斥量变量时,使用静态初始化方式进行初始化。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
这种方式将互斥量初始化为默认属性,并且已经被锁住。
- 动态初始化:在运行时使用函数进行初始化。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// mutex : 要初始化的互斥量
// attr:NULL
销毁互斥量
在销毁互斥量时,需要注意以下几点:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要显式销毁。 - 不要销毁一个已经加锁的互斥量。如果一个互斥量在被加锁的状态下被摧毁,其它线程可能无法正确解锁,导致死锁或其它错误。
- 已经摧毁的互斥量不能再被使用。一旦互斥量被销毁,就不能对其进行加锁操作。若后续还有线程尝试对已经摧毁的互斥量进行加锁,可能会导致未定义的行为。
互斥量使用完成之后,我们使用 pthread_mutex_destroy 函数来进行销毁,释放相关资源,其函数定义如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
互斥量的加锁和解锁是通过调用对应的函数来实现的。在C语言中,可以使用 pthread_mutex_lock 函数对互斥量进行加锁,使用 pthread_mutex_unlock 函数对互斥量进行解锁。它们的函数定义如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
函数调用成功返回0,失败则返回错误号。
调用 pthread_ lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其它线程已经锁定互斥量,或者存在其它线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_ lock 调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
示例:上面的抢票程序中出现了问题。现在在上述的代码中引入互斥量,在每一个线程进入临界区之前都要先申请锁,只有拥有该临界区的锁才能对临界区进行访问,临界区访问结束之后就要释放锁资源。
// 票数计票器 - 临界资源,可能因为共同访问,造成数据不一致的问题
int tickets = 10000;
pthread_mutex_t mutex;
void *getTickets(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
// 临界区,只需要对临界区加锁,加锁的粒度越细越好,加锁的本质就是让线程执行临界区代码串行化
// 锁保护的是临界区,任何线程执行临界区代码访问临界资源,都必须先申请这把锁,那么锁本身也是临界资源。
// pthread_mutex_lock:竞争和申请锁的过程,就是原子的!
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
cout << name << " 抢到了票,票的编号:" << tickets << endl;
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
cout << "票已经售完......" << endl;
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex,nullptr);
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
运行代码如下所示:
在加锁的临界区中,需要注意保证锁的申请和释放成对出现,临界区的代码尽量简洁,合理控制锁的粒度,以提高并发性能和线程的公平性。
互斥量实现的原理
在上面的例子中,我们已经知道了单纯的 i++
或者 i--
都不是原子的,可能会有数据一致性问题。
为了实现互斥锁操作,大多数体系结构都提供了 swap 和 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在将 lock 和 unlock 的伪代码改一下:
在这段代码中,我们认为 mutex 的初始值为1(表示锁被占用),al 是一个寄存器,当线程申请锁时,需要执行以下步骤:
- 首先将 al 寄存器中的值清零,然后使用 xchgb 指令将 al 寄存器的值与 mutex 进行交换。
- 如果交换成功,即原来的 mutex 为1,表示成功获取到锁,可以进入临界区执行操作。
- 如果交换失败,即原来的 mutex 为0,表示锁已经被其它线程占用,当前线程需要挂起等待。
- 在此期间,其它线程可能会释放锁,使得当前线程能够再次竞争申请锁。
如下,若此时内存中的 mutex 的值为1,线程申请锁时先将寄存器中的值清为0,然后将 al 寄存器中的值与内存中的 mutex 值进行交换。
交换完成之后,寄存器 al 中的值变为1,在该线程申请锁成功。
此时其它线程再次申请锁,但内存中的 mutex 的值为0,交换寄存器 al 和内存的值之后,寄存器 al 的值仍然为0,因此该线程申请锁失败,需要被挂起等待,直至申请到锁的线程释放锁之后才可以再次竞争锁资源。
当线程释放锁时,需要将 mutex 的值设置为1,表示锁已经被释放,其它线程可以继续竞争锁并进入临界区。同时,需要唤醒等待 mutex 的线程,让它们继续竞争申请锁。
申请锁的过程为什么是原子的呢❓
竞争锁的本质就是哪一个线程首先执行交换指令成功,执行成功并且 al 中的值为1,则申请锁成功。交换指令仅仅只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行,因此线程申请锁的过程是原子的。
可重入 vs 线程安全
概念
线程安全:多个代码并发执行同一段代码时,不会出现不同的结果或者不确定的行为。常见对全局变量或者静态变量进行操作并且没有锁保护的情况下,会出现该问题。
重入:指同一个函数在被不同执行流多次调用时,当前一个流程还没有执行完,就有其它的执行流再次进入,我们称之为可重入。一个函数在可重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况
- 每个线程对全局变量和静态变量只有读取权限,而没有写权限,一般来说这些线程是安全的。
- 类或接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致接口的执行结果存在二义性。
以上的情况只是一些常见的线程安全情况,并不能覆盖所有可能的问题。实际开发中,需要根据具体的场景和需求来判断线程的安全性,并采取相应的线程同步机制来保证线程的安全。
常见不可重入的情况
- 调用了 malloc/free 函数,因为 malloc 函数通常使用全局链表来管理堆的,多个线程同时调用 malloc/free 可能会导致竞争条件,从而导致不可预测的结果。
- 调用了标准 I/O 库函数,标准 I/O 库的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
常见可重入的情况
- 不使用全局变量或静态变量,这样就不会出现多个线程同时访问和修改同一个全局数据的问题。
- 不使用 malloc 或 new 开辟出的空间,因为多个线程同时访问和释放同一个内存块可能会导致竞争条件。
- 不调用不可重入函数,例如:标准 I/O 库或使用全局数据结构的函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
- 若一个函数是可重入的,那么就是线程安全的。
- 函数是不可重入的,那就不能由多个线程调用,有可能会引入线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
即可重入与线程安全有一定的联系,可重入函数一定是线程安全的,不可重入函数可能会引发线程安全问题。全局变量的存在会破坏函数的可重入性和线程安全性。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种,它可以被多个线程同时调用而不会相互干扰或产生不可预测的结果。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见锁概念
死锁
死锁是指一组进程中各个进程均占有不会释放的资源,但因互相申请被其它线程所占用不会释放的资源而处于的一种永久等待状态。
死锁的产生通常需要满足以下四个条件,也被称为死锁的必要条件:
- 互斥条件:一个资源只能被一个线程持有,即资源不能共享。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已经获得的资源保持不放。
- 不可剥夺条件:线程已经获得的资源不能被其它线程强制性的剥夺。
- 循环等待条件:存在一个线程的资源请求链,使得每个线程都在等待下一个线程所持有的资源。
当这四个条件同时满足时,就可能导致死锁的发生。
为了避免死锁的产生,可以采取一些常见的方案:
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁的算法:死锁检测算法、银行家算法。
死锁产生的示例:以下代码实现了一个模拟死锁的情况。由于线程t1和线程t2获取锁的信息不同,且在获取锁的过程中会相互等待对方释放锁,因此可能会产生死锁的情况。如果 t1 先获取了 mutexA 锁,而 t2 先获取了 mutexB 锁,那么它们会互相等待对方释放锁,导致程序无法继续执行。
#include <iostream>
#include <pthread.h>
using namespace std;
// 模拟死锁问题
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
void *startRountine1(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexA);
pthread_mutex_lock(&mutexB);
cout << "我是线程1, tid:" << pthread_self() << endl;
pthread_mutex_unlock(&mutexA);
pthread_mutex_unlock(&mutexB);
}
}
void *startRountine2(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexB);
pthread_mutex_lock(&mutexA);
cout << "我是线程2, tid:" << pthread_self() << endl;
pthread_mutex_unlock(&mutexB);
pthread_mutex_unlock(&mutexA);
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, startRountine1, nullptr);
pthread_create(&t2, nullptr, startRountine2, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
一把锁会不会发生死锁问题❓
在大型项目中,没有注意的情况下,对一个临界区重复加了同一把锁。线程已经持有锁没有释放的情况下,该线程又去申请该锁,由于锁已经被自己持有,因此会一致等待自己释放锁,导致程序无法正常执行。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
// 一把锁会不会导致死锁问题
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int cnt = 0;
void *startRoutine(void *args)
{
string name = static_cast<char *>(args);
while (true)
{
// 没有注意,重复加锁的情况
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
cout << name << " count : " << cnt-- << endl;
pthread_mutex_unlock(&mutex);
sleep(1);
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&t2, nullptr, startRoutine, (void *)"thread 2");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
运行该程序,结果如下: