如何理解临界区和临界资源❓
在多线程编程中,临界资源是指被多个线程共享的资源,例如共享的变量、共享的数据结构等。临界区指的是访问临界资源的代码段,也就是对临界资源进行操作的代码段。
示例:下列代码中的主线程创建了一个新线程,有一个全局计数器 count ,在新线程中每秒对计数器进行加1,主线程每隔一秒打印 count 的值。在这个例子中,count 就是临界资源,它被多个执行流共享;主线程中的 cout 和新线程中的 count++ 就是临界区,因为它们都对临界资源 count 进行了访问。
#include
#include
#include
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--
时,需要三个步骤才能完成。因此,当线程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--
只是一行代码,但实际上被编译器解析下来之后需要执行三个指令。即对一个变量进行 --
操作并不是原子的。 (需要注意的是,不同的编译器对于 --
操作的实现方式是不同的。有些语言和编译器可能会提供原子操作的支持。)
要解决上述问题,需要做到以下三点:
要做到以上三点,实际上需要一把锁。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
时,可能会遇到以下情况:
示例:上面的抢票程序中出现了问题。现在在上述的代码中引入互斥量,在每一个线程进入临界区之前都要先申请锁,只有拥有该临界区的锁才能对临界区进行访问,临界区访问结束之后就要释放锁资源。
// 票数计票器 - 临界资源,可能因为共同访问,造成数据不一致的问题
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 是一个寄存器,当线程申请锁时,需要执行以下步骤:
如下,若此时内存中的 mutex 的值为1,线程申请锁时先将寄存器中的值清为0,然后将 al 寄存器中的值与内存中的 mutex 值进行交换。
交换完成之后,寄存器 al 中的值变为1,在该线程申请锁成功。
此时其它线程再次申请锁,但内存中的 mutex 的值为0,交换寄存器 al 和内存的值之后,寄存器 al 的值仍然为0,因此该线程申请锁失败,需要被挂起等待,直至申请到锁的线程释放锁之后才可以再次竞争锁资源。
当线程释放锁时,需要将 mutex 的值设置为1,表示锁已经被释放,其它线程可以继续竞争锁并进入临界区。同时,需要唤醒等待 mutex 的线程,让它们继续竞争申请锁。
申请锁的过程为什么是原子的呢❓
竞争锁的本质就是哪一个线程首先执行交换指令成功,执行成功并且 al 中的值为1,则申请锁成功。交换指令仅仅只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行,因此线程申请锁的过程是原子的。
线程安全:多个代码并发执行同一段代码时,不会出现不同的结果或者不确定的行为。常见对全局变量或者静态变量进行操作并且没有锁保护的情况下,会出现该问题。
重入:指同一个函数在被不同执行流多次调用时,当前一个流程还没有执行完,就有其它的执行流再次进入,我们称之为可重入。一个函数在可重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
以上的情况只是一些常见的线程安全情况,并不能覆盖所有可能的问题。实际开发中,需要根据具体的场景和需求来判断线程的安全性,并采取相应的线程同步机制来保证线程的安全。
即可重入与线程安全有一定的联系,可重入函数一定是线程安全的,不可重入函数可能会引发线程安全问题。全局变量的存在会破坏函数的可重入性和线程安全性。
死锁是指一组进程中各个进程均占有不会释放的资源,但因互相申请被其它线程所占用不会释放的资源而处于的一种永久等待状态。
死锁的产生通常需要满足以下四个条件,也被称为死锁的必要条件:
当这四个条件同时满足时,就可能导致死锁的发生。
为了避免死锁的产生,可以采取一些常见的方案:
避免死锁的算法:死锁检测算法、银行家算法。
死锁产生的示例:以下代码实现了一个模拟死锁的情况。由于线程t1和线程t2获取锁的信息不同,且在获取锁的过程中会相互等待对方释放锁,因此可能会产生死锁的情况。如果 t1 先获取了 mutexA 锁,而 t2 先获取了 mutexB 锁,那么它们会互相等待对方释放锁,导致程序无法继续执行。
#include
#include
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
#include
#include
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;
}
运行该程序,结果如下: