一. 什么是互斥?
在介绍什么是互斥之前需要了解下面三个概念:
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何时刻,保证有且只有一个执行流进入临界区、访问临界资源,这就叫做互斥。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。另外原子性的操作都是保证互斥的。
二. 为什么要有互斥?
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程私有栈空间内,这种情况,局部变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过对这些数据的共享,完成线程之间的通信。但是这样多个线程并发的操作共享变量,会带来一些问题。
下面是一段黄牛抢票的代码。定义全局变量count = 1000代表一共有一千张票,然后我们在主线程中创建四个新线程,每一个新线程执行相同的抢票逻辑,而主线程阻塞等待新线程把票抢完。具体新线程的抢票逻辑中应该包含以下三步:
- 先检测是否还有票。if(count > 0)?
- 如果有票的话让count减一,然后又回到第一步再抢新的一张票。
- 如果没票的话就退出抢票逻辑,线程终止。
#include <stdio.h> #include <unistd.h> #include <pthread.h> int count = 1000; void* GetTickets(void* arg) { while(1) { if(count > 0) { // 睡眠10000毫秒,模拟买票时的流程(访问数据库,更新数据库等等) usleep(10000); // 买到了一张票 printf("[thread %d] get ticket No.%d\n", (int)arg, count); --count; } else { break; } } } int main() { // 1、创建4个新线程 pthread_t tids[4]; for(int i = 0; i < 4; ++i) { pthread_create(&tids[i], NULL, GetTickets, (void*)(i+1)); } // 2、阻塞等待4个新线程把票抢完 for(int i = 0; i < 4; ++i) { pthread_join(tids[i], NULL); } return 0; }
编译运行:
发现最终结果有误:
该错误是由以下两个原因组成的:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep 是个模拟漫长业务的过程。在这个漫长的业务过程中,可能有其他线程会进入该代码段。
假设只有一张票了,线程1的 if 语句判断条件为真后进入抢票逻辑,正在usleep时线程1的时间片到了应该轮到线程2,线程2完成抢票后count等于0刚好它的时间片结束,又轮到线程1接着之前的逻辑继续执行,这时count自减后变为-1。
count相当于临界资源,其他对count进行操作的代码是临界区:
要解决以上问题,需要做到三点:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
即所有时间内,买票的逻辑只能是一个线程进入,只有当前线程买完票后,其他线程才能进来买票,要做到这个,我们需要一把锁。Linux上把提供的这把锁叫互斥量。
三. 互斥量实现互斥
1. 互斥量概念
互斥量是一种锁,故也叫互斥锁,在访问共享资源时对其加锁,访问结束时解锁。这样可以保证在任意时间内,只有一个线程处于临界区内,任何其他要进入该临界区的线程都要对锁进行检测,如果该锁已经被某一线程所申请,则检测线程会被阻塞,直到该锁被释放。
2. 资源等待队列和运行等待队列
2.1 资源等待队列的理解
进程等待某种资源(锁、网卡、显示器等),在OS层面就是将线程的task_struct结构体放入相应的资源等待队列中,并让线程的状态变为S。
用户层面看到的是自己的线程卡主不动了,即我们得线程被阻塞住了。
2.2 运行等待队列的理解
在多线程并行运行时,每一个正在运行线程的task_struct都会被放到一个运行等待队列中等待CPU的调度,在CPU中运行的线程有一定的时间限制,当时间片到了就会被剥离出CPU重新排队,轮到下一个线程使用CPU,只是因为时间片很短加之CPU处理效率高所以我们看到的是多个线程在同时运行。(这里说的是单核)
正在CPU中运行的线程如果想要申请一些资源,比如锁、网卡、显示器等,这些资源刚好被其他线程占用,但你这个线程没有这个资源就不能顺序运行下去,这时操作系统会把这个正在CPU中运行的线程剥离出CPU并把它的task_struct放到对应的资源等待队列中阻塞等待,直到申请到了需要的资源后才会重新放到运行等待队列中。
被剥离出CPU的线程,它们的上下文(也就是CPU寄存器和程序计数器)数据会被保存起来,然后加载新线程的上下文到寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。而保存下来的上下文数据,会存储在系统内核中,并在任务重新调度执行的时候再加载进来。
3. 互斥量的接口函数
3.1 定义和初始化互斥锁
互斥锁类型为pthread_mutex_t,我们可以像创建一个整型变量一样创建互斥锁:
pthread_mutex_t 互斥锁变量名称;
利用pthread_mutex_init()函数初动态始化一个互斥锁变量,函数原型如下:
第一个参数mutex是之前定义的互斥锁变量的地址,第二个参数为该互斥锁的属性。互斥锁分为下面四种类型:
- PTHREAD_MUTEX_TIMED_NP:这时默认值,也就是普通锁。当一个线程加锁以后,其它申请该锁的线程组成一个资源等待队列,并在解锁后按优先级获得锁。这种解锁策略保证了资源分配的公平性。
- PTHREAD_MUTEX_RECURSIVE_NP:嵌套锁,允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。如果是不同线程请求,则在加锁线程解锁后重新竞争。
- PTHREAD_MUTEX_ERRORCHECK_NP:检错锁,如果同一个线程请求同一个锁,则返回EDEADLK错误,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。这样就保证当不允许多次加锁时,不会出现最简单情况下的死锁。
- PTHREAD_MUTEX_ADAPTIVE_NP:适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。
不一定到用函数来初始化锁,以下语句可以将一个互斥锁在定义时直接初始化:
这种直接初始化的方法是一种静态初始化,它将“声明”、“定义”、“初始化”一气呵成,包括最终锁的销毁也不用我们自己动手。
3.2 销毁互斥锁
pthread_mutex_destroy()用于注销一个互斥锁,函数原型如下:
使用该函数时,以下三点需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
不要销毁一个已经加锁的互斥量。
释放一个互斥锁即意味着没有线程再占有该锁,要确保后面不会有线程再尝试加锁。
3.3 申请互斥量(加锁)
在创建并初始化我们创建的互斥锁后,便可以给互斥锁上锁,其函数原型如下:
该函数用来给互斥量上锁,互斥量一旦被上锁后,其他线程如果再想申请该互斥量,就会阻塞在这个操作上。如果在此之前,该互斥量已被其他线程上锁,那么该操作将会一直阻塞在这个地方,直到获得该锁为止。
如果不想上锁碰到一直阻塞的情况,可以用非阻塞方式上锁,其函数原型如下:
如果此时互斥量没有被上锁,那么会返回0,并对该互斥量上锁;如果互斥量已经被上锁,那么会立即返回EBUSY错误。
3.4 释放互斥量(解锁)
下面的函数可以用来给互斥量解锁,这样其它等待该锁的线程才有机会获得该锁,否则其它没申请到锁的线程将会永远阻塞,其函数原型如下:
3.5 加锁完善黄牛抢票代码
下面我们通过加入一把互斥锁来完善前面的黄牛抢票代码:
- 定义一个全局的互斥锁变量。
- 主线程在创建新线程前先完成互斥锁的初始化,最后等待子线程抢完票后负责销毁互斥锁。
- 新线程确保进来买票前加锁和出去时解锁,如下图所示:
引入互斥锁后的黄牛抢票代码:
#include <stdio.h> #include <unistd.h> #include <pthread.h> int count = 1000; pthread_mutex_t mutex; void* GetTickets(void* arg) { while(1) { pthread_mutex_lock(&mutex); if(count > 0) { usleep(1000); printf("[thread %d] get ticket No.%d\n", (int)arg, count); --count; pthread_mutex_unlock(&mutex); } else { pthread_mutex_unlock(&mutex); break; } } } int main() { // 1、创建新线程之前先把锁初始化 pthread_mutex_init(&mutex, NULL); // 2、创建4个新线程 pthread_t tids[4]; for(int i = 0; i < 4; ++i) { pthread_create(&tids[i], NULL, GetTickets, (void*)(i+1)); } // 3、阻塞等待4个新线程把票抢完 for(int i = 0; i < 4; ++i) { pthread_join(tids[i], NULL); } // 4、等待新线程把票抢完后释放锁 pthread_mutex_destroy(&mutex); return 0; }
编译运行,发现票不多不少正确地抢完了,但怎么全被1号线程抢了?????
并不是说1号线程是个老黄牛,而是因为我们在主线程中先创建的是1号线程,所以1号线程先申请到锁第一个去抢票;在它抢完票释放锁后,它对锁的竞争性最强所以能够马上又申请到锁继续买票。这就好像一个人买完一张票脚刚踏出门马上又进门重复买票一样。
想要解决这个问题,只需让刚买到票后释放锁的线程睡眠几秒,这样让其它处在资源等待队列的线程可以有机会去申请到锁:
编译运行,这次每个线程按顺序排队买票的:
问题:加锁成功的线程在临界区内是否可以进行线程切换?
答:可以的,并且切换后也不会出现任何问题。因为当前线程是拿着锁被切走的,如果当前线程不解锁,其他线程不可能进入临界区进行资源访问。况且其他线程访问临界区前先要完成加锁,检测到互斥锁已经被加锁,它们只能进入资源等待队列挂起等待锁的释放。
4. 互斥锁实现互斥的原理
下面是互斥锁加锁、解锁的伪代码图,主要的有四条语句。其中前三条为加锁语句,第四条为解锁语句:
知识铺垫:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
每个线程在CPU中都有一组自己私有的寄存器(主要说明的是数据私有)。线程要被切换前,属于该线程寄存器的数据会被保存起来放到内核,再次调度该线程时操作系统会恢复该线程寄存器的数据。
除了私有栈外,进程地址空间的其他数据所有线程共享。
下图是对互斥锁原理图中标记语句的解释说明:
在整个加锁的过程中只有一个数值1存在,这个1要么保存在某个线程的私有的寄存器中,要么保存在共享的互斥锁变量mutex里。如果某个线程的寄存器中的值为1,说明该线程已经加锁成功。
问题:锁的存在是为了保护临界资源,锁本身也是临界资源(所以线程都要加锁、解锁),谁来保护锁呢?
答:锁不需要保护,因为锁的加锁、解锁操作本身就是互斥的。
先来说加锁,加锁主要有三条语句,当一个线程执行其中一条语句时,其它的线程插入会不会导致加锁出错呢?答案是不会的,因为真正影响决定加锁成功与否的只是一条语句二。而系统提供的exchange语句保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
比如线程1执行完语句一后正要执行语句二,这时切换到线程2运行,操作系统会把线程1的寄存器数据(%al = 0)保存起来,下次调用时恢复。线程二完成了语句一、二,此时它共享的mutex = 0、寄存器值为1,当线程2正欲执行指令三时时间片到了,轮到线程1运行,同样线程2的寄存器的值(%al = 1)被操作系统保存起来。线程1接着上次执行语句二,这时它的寄存器和mutex交换数值后两者都为0(1已经被线程2执行语句二时交换走了),线程1继续执行语句三,判断不大于0,申请锁失败重新回到语句1申请锁,不过在线程2释放锁之前,线程1是申请不到锁的。
解锁的话只有一条语句,所以它也是原子操作。而且按照正常逻辑,我们应该做到只有申请锁成功的线程访问完临界区后才有机会去释放锁,其他申请锁失败的线程是没机会释放锁的,即不存在同时去竞争解锁的道理。
5. 互斥锁使用注意事项
互斥锁是临界资源保护的使用场景,不要乱用,锁的使用是会消耗CPU资源的(线程如果获取不到锁会进行线程切换)。
获取锁之后不要sleep太久。
锁不要保护的太大,够用就行。
不要交叉使用两把锁或者一个线程多次申请同一把锁,这会造成死锁。
四. 死锁
1. 概念
死锁是指在一组进程中的各个线程均占有不会释放的资源,但因互相申请被其他线程所占用不会释放的资源而处于的一种永久等待状态。
下面演示一段最简单的死锁:一个线程自己先申请到锁,然后在锁未释放前自己又申请该锁,这时系统发现锁已经被申请了,就把这个线程放到资源等待队列中阻塞等待锁资源的释放;由于锁被自己拿着,但自己又要阻塞等待锁的释放导致该线程被自己阻塞了。
#include <stdio.h> #include <pthread.h> pthread_mutex_t mutex; void* Routine(void* arg) { // 1、申请锁 pthread_mutex_lock(&mutex); // 2、执行10次打印,并且自己重复申请锁 for(int i = 0; i < 10; ++i) { printf("I am new thread,runing\n"); pthread_mutex_lock(&mutex); } // 3、释放锁 pthread_mutex_unlock(&mutex); // 4、新线程退出 pthread_exit(NULL); } int main() { // 1、创建新线程之前初始化锁 pthread_mutex_init(&mutex, NULL); // 2、创建新线程 pthread_t tid; pthread_create(&tid, NULL, Routine, NULL); // 3、阻塞等待新线程结束 pthread_join(tid, NULL); // 4、销毁锁 pthread_mutex_destroy(&mutex); return 0; }
编译运行,发现新线程阻塞住了:
2. 产生死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
3. 避免死锁的建议
破坏死锁的四个必要条件(任何一个都行)。
加锁顺序一致。
避免锁未释放的场景。
资源一次性分配。
4. 避免死锁的算法
死锁检测算法
银行家算法
五. 可重入VS线程安全
1. 相关概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
2. 常见的线程不安全的情况
不保护共享变量的函数。
函数状态随着被调用,状态发生变化的函数。
返回指向静态变量指针的函数。
调用线程不安全函数的函数。
3. 常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
4. 常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
5. 常见可重入的情况
不使用全局变量或静态变量。
不使用用malloc或者new开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都有函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
6. 可重入与线程安全联系
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
7. 可重入与线程安全区别
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
六. 什么是同步?
在保证数据安全的前提下(一般使用加锁方式),让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
七. 为什么要有同步?
同步是为了让多线程协同高效地完成某件事情。比如在互斥部分的黄牛抢票代码,我们引入互斥锁确保了临界资源的数据安全,但是发现票被同一个线程全抢走了,而其他线程处于饥饿状态,这是因为这个线程刚释放锁,它对锁的竞争力比其他线程强,所以马上又能申请到锁,对此我们的解决办法是让每一个线程买到一张票释放锁后自己sleep等待几秒,让其他线程能够按照特定的顺序去买票,但这样的解决办法不提倡,因为sleep等待降低了多线程整体的运行效率,且在其它更为复杂的场景下sleep是不能解决同步问题的。
像上面这样因为时序问题,从而导致程序异常,我们称之为竞态条件。为了让多线程高效有序的同步运行,我们可以使用条件变量。
八. 条件变量实现同步
1. 条件变量说明
与互斥锁不同,同步条件变量是用来等待而不是上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止,通常条件变量和互斥锁同时使用。
条件变量可以使线程睡眠,等待某种条件出现,条件变量是线程间共享全局变量进行同步的一种机制。条件变量的本质是一个PCB等待队列,队列中存的是线程的PCB:
同步的前提是在保证数据安全的情况下,所以条件的检测是在互斥锁的保护下进行的。通常一个线程加锁后要对某条件变量进行条件判断,如果该条件为假,则线程自动阻塞(PCB放入条件等待队列),并释放互斥锁;如果另一个线程条件认为条件成熟了,它会发信号给关联的条件变量,唤醒一个或多个在该条件下等待的线程,这些线程重新申请互斥锁,重新评价条件。
2. 条件变量的接口函数
2.1 条件变量的初始化、销毁
条件变量采用的数据类型是pthread_cond_t,该类型定义出来的变量在使用之前必须进行初始化,初始化包括以下两种方式:
静态:可以把常量PTHREAD_COND_INITIALIZER赋值给静态分配的条件变量。
动态:调用pthread_cond_init函数动态创建条件变量。释放动态条件变量的内存空间之前,需用pthread_cond_destroy函数对其进行清理。
这两个函数成功返回0,失败则返回错误码。当pthread_cond_init函数的attr参数为NULL时,会创建一个默认属性的条件变量。
2.1 等待条件
pthread_cond_wait内部实行逻辑:
将调用pthread_cond_wait函数线程的PCB放到条件等待队列当中
释放互斥锁
等待被唤醒
线程被唤醒之后:
从PCB从条件等待队列当中移除出来
抢占互斥锁
情况一:拿到互斥锁,pthread_cond_wait函数就返回了
情况二:没有抢到互斥锁,阻塞在pthread_cond_wait函数内部的抢锁逻辑当中
2.3 通知条件
这两个函数用于通知线程条件已经满足,向先线程或条件发送信号。必须注意的是,一定要在改变条件状态以后,再给线程发信号。pthread_cond_signal()激活一个等待该条件的线程,存在多个等待线程时,按入队顺序激活其中一个,而pthread_cond_broadcast()则激活所有等待线程。
3. 条件变量使用举例
我们在主线程中创建4个新线程,这4个新线程死循环地在条件变量里等待,主线程每输入一个回车字符就唤醒一个新线程:
#include <stdio.h> #include <pthread.h> pthread_mutex_t mutex; pthread_cond_t cond; void* Routine(void* arg) { pthread_detach(pthread_self()); while(1) { pthread_cond_wait(&cond, &mutex); printf("I am thread %d,runing\n", (int)arg); } } int main() { // 1、初始化全局的互斥锁和条件变量 pthread_mutex_init(&mutex, NULL); pthread_cond_init(&cond, NULL); // 2、创建4个新线程 pthread_t tids[4]; for(int i = 0; i < 4; ++i) { pthread_create(&tids[i], NULL, Routine, (void*)(i + 1)); } // 3、每输入一个回车唤醒一个条件等待队列里的线程 while(1) { getchar(); pthread_cond_signal(&cond); } // 4、销毁互斥锁和条件变量 pthread_mutex_destroy(&mutex); pthread_cond_destroy(&cond); return 0; }
编译运行,发现每输入一个字符就唤醒一个在条件变量下等待的线程,这些线程被唤醒的次序就是它们被放入条件等待队列里的次序:
这次我们使用pthread_cond_broadcast(),主线程中每输入一个字符就唤醒所有在条件变量下等待的线程:
编译运行:
4. 条件变量与互斥锁的区别
互斥锁要么锁住,要么被解开,二值状态,类似二值信号量。
互斥锁是为了山锁而设计的,而条件变量是为了等待而设计的。