二、可重入与线程安全
1、概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
2、常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
3、常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4、常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5、常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用
malloc
或者new
开辟出的空间 - 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
6、可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
7、可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
三、死锁问题
1、死锁的概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
例如:有一份临界资源需要同时拿到A,B两把锁才能进行访问,线程1
拿到了A锁,线程2
拿到了B锁,然后线程1
,线程2
都想访问这份临界资源,于是相互申请对方的锁,但是两方都不释放锁,于是产生了僵持,这就是死锁。
单执行流可能产生死锁吗?
单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁,然后使用ps
命令查看线程的状态。
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; // 静态分配一把锁 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int main() { cout << "I am thread" << endl; // 申请锁 pthread_mutex_lock(&mutex); cout << "I got a lock" << endl; // 再次申请锁 pthread_mutex_lock(&mutex); cout << "I got a lock again" << endl; // 解锁 pthread_mutex_unlock(&mutex); pthread_mutex_unlock(&mutex); return 0; }
可以看到,线程被死锁了
2、死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
- . 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
注意: 这是死锁的四个必要条件,也就是说只要是死锁,就一定同时满足这四个条件。
3、避免死锁
核心是:破坏死锁的四个必要条件,必要条件被破坏,就不可能形成死锁。
- 不加锁,不加锁当然不会产生死锁问题,当一个方案可以加锁完成也可以不加锁完成时,优先选择不加锁就能完成的!
- 加锁顺序一致,例如A,B,C三把锁,必须依次获取,顺序不能乱。
- 避免锁未释放的场景, 锁不释放,再次申请时当然会产生死锁问题。
- 主动释放锁,当我们申请锁失败的时候,我们可以主动释放自己的锁,这个可以借助
pthread_mutex_trylock()
,与pthread_mutex_unlock()
函数完成。 - 控制线程统一释放锁,利用一个控制线程判断如果产生了死锁问题,就将所有的锁全部释放,重新竞争。(锁的申请与释放锁可以不是同一个线程)
避免死锁也有一些其他算法如:死锁检测算法,银行家算法感兴趣的可以了解一下。
四、Linux线程同步
1、同步引入与概念
有了加锁以后我们多线程访问临界资源导致数据不一致性的问题确实得到了解决,但是单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后由于条件不满足于是什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
为了解决饥饿问题,于是引入了线程同步。
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件: 指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。竞态条件会产生超出预期的情况因此竞态条件是一种需要被避免的情形。
2、条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
- 一个线程使用等待条件变量而被挂起。
- 另一个线程使条件成立后唤醒挂起的线程。
条件变量的使用总是和一个互斥量结合在一起。
例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中这个线程才被唤醒,这种情况就需要用到条件变量。
有了这个条件变量以后该线程也不必不断的申请锁,使用队列里面的数据,结果没有数据,于是释放锁的循环,同时其他线程也能够有机会拿到锁,从而避免了饥饿问题。
①初始化条件变量
动态分配
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
cond
:需要初始化的条件变量。attr
:初始化条件变量的属性,一般设置为NULL即可。
返回值说明:
- 条件变量初始化成功返回0,失败返回错误码。
静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
静态分配的条件变量不用我们手动销毁。
②销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 条件变量销毁成功返回0,失败返回错误码。
③等待条件变量满足
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
当该函数成功返回时,调用该函数的线程会被挂在等待条件变量的等待队列里面,并且该函数也会自动释放该线程持有的锁。
参数说明:
- cond:等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
④唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_signal
函数用于唤醒等待队列中首个线程。pthread_cond_broadcast
函数用于按顺序唤醒等待队列中的全部线程。
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
下面一份代码,我们假设条件全部都是不满足的,让多个线程在等待条件变量下面挂起,等待3秒以后条件满足,主线程再让所有的线程依次唤醒,继续执行。
#include <iostream> #include <cstdio> #include <pthread.h> #include <unistd.h> using namespace std; // 定义锁和条件变量 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; void* threadRoutine(void* args) { char* s = static_cast<char*> (args); while (true) { pthread_mutex_lock(&mutex); // 假设要访问临界资源的条件不成立 pthread_cond_wait(&cond, &mutex); cout << s << "active" << endl; pthread_mutex_unlock(&mutex); } } int main() { pthread_t tname[3]; // 创建线程 for (int i = 0; i < 3; i++) { char* ps = new char[32]; snprintf(ps, 32, "thread-%d", i); pthread_create(tname + i, nullptr, threadRoutine, ps); } sleep(3); // 3s以后唤醒等待队列里面的线程 cout << "main thread wake up ..." << endl; while (true) { pthread_cond_signal(&cond); sleep(1); } return 0; }
运行结果:
我们发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行等待,所以我们能够看到一个循环周转的现象。
3、为什么pthread_cond_wait需要互斥量的理解
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以在调用
pthread_cond_wait
函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。 - 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。