在介绍什么是互斥之前需要了解下面三个概念:
互斥:任何时刻,保证有且只有一个执行流进入临界区、访问临界资源,这就叫做互斥。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。另外原子性的操作都是保证互斥的。
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程私有栈空间内,这种情况,局部变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过对这些数据的共享,完成线程之间的通信。但是这样多个线程并发的操作共享变量,会带来一些问题。
下面是一段黄牛抢票的代码。定义全局变量count = 1000代表一共有一千张票,然后我们在主线程中创建四个新线程,每一个新线程执行相同的抢票逻辑,而主线程阻塞等待新线程把票抢完。具体新线程的抢票逻辑中应该包含以下三步:
#include
#include
#include
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;
}
编译运行:
发现最终结果有误:
该错误是由以下两个原因组成的:
假设只有一张票了,线程1的 if 语句判断条件为真后进入抢票逻辑,正在usleep时线程1的时间片到了应该轮到线程2,线程2完成抢票后count等于0刚好它的时间片结束,又轮到线程1接着之前的逻辑继续执行,这时count自减后变为-1。
count相当于临界资源,其他对count进行操作的代码是临界区:
要解决以上问题,需要做到三点:
即所有时间内,买票的逻辑只能是一个线程进入,只有当前线程买完票后,其他线程才能进来买票,要做到这个,我们需要一把锁。Linux上把提供的这把锁叫互斥量。
互斥量是一种锁,故也叫互斥锁,在访问共享资源时对其加锁,访问结束时解锁。这样可以保证在任意时间内,只有一个线程处于临界区内,任何其他要进入该临界区的线程都要对锁进行检测,如果该锁已经被某一线程所申请,则检测线程会被阻塞,直到该锁被释放。
互斥锁类型为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
:适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。不一定到用函数来初始化锁,以下语句可以将一个互斥锁在定义时直接初始化:
这种直接初始化的方法是一种静态初始化,它将“声明”、“定义”、“初始化”一气呵成,包括最终锁的销毁也不用我们自己动手。
pthread_mutex_destroy()用于注销一个互斥锁,函数原型如下:
使用该函数时,以下三点需要注意:
在创建并初始化我们创建的互斥锁后,便可以给互斥锁上锁,其函数原型如下:
该函数用来给互斥量上锁,互斥量一旦被上锁后,其他线程如果再想申请该互斥量,就会阻塞在这个操作上。如果在此之前,该互斥量已被其他线程上锁,那么该操作将会一直阻塞在这个地方,直到获得该锁为止。
如果不想上锁碰到一直阻塞的情况,可以用非阻塞方式上锁,其函数原型如下:
如果此时互斥量没有被上锁,那么会返回0,并对该互斥量上锁;如果互斥量已经被上锁,那么会立即返回EBUSY错误。
下面的函数可以用来给互斥量解锁,这样其它等待该锁的线程才有机会获得该锁,否则其它没申请到锁的线程将会永远阻塞,其函数原型如下:
下面我们通过加入一把互斥锁来完善前面的黄牛抢票代码:
引入互斥锁后的黄牛抢票代码:
#include
#include
#include
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号线程先申请到锁第一个去抢票;在它抢完票释放锁后,它对锁的竞争性最强所以能够马上又申请到锁继续买票。这就好像一个人买完一张票脚刚踏出门马上又进门重复买票一样。
想要解决这个问题,只需让刚买到票后释放锁的线程睡眠几秒,这样让其它处在资源等待队列的线程可以有机会去申请到锁:
编译运行,这次每个线程按顺序排队买票的:
问题:加锁成功的线程在临界区内是否可以进行线程切换?
答:可以的,并且切换后也不会出现任何问题。因为当前线程是拿着锁被切走的,如果当前线程不解锁,其他线程不可能进入临界区进行资源访问。况且其他线程访问临界区前先要完成加锁,检测到互斥锁已经被加锁,它们只能进入资源等待队列挂起等待锁的释放。
下面是互斥锁加锁、解锁的伪代码图,主要的有四条语句。其中前三条为加锁语句,第四条为解锁语句:
知识铺垫:
在整个加锁的过程中只有一个数值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是申请不到锁的。
解锁的话只有一条语句,所以它也是原子操作。而且按照正常逻辑,我们应该做到只有申请锁成功的线程访问完临界区后才有机会去释放锁,其他申请锁失败的线程是没机会释放锁的,即不存在同时去竞争解锁的道理。
死锁是指在一组进程中的各个线程均占有不会释放的资源,但因互相申请被其他线程所占用不会释放的资源而处于的一种永久等待状态。
下面演示一段最简单的死锁:一个线程自己先申请到锁,然后在锁未释放前自己又申请该锁,这时系统发现锁已经被申请了,就把这个线程放到资源等待队列中阻塞等待锁资源的释放;由于锁被自己拿着,但自己又要阻塞等待锁的释放导致该线程被自己阻塞了。
#include
#include
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;
}
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
在保证数据安全的前提下(一般使用加锁方式),让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
同步是为了让多线程协同高效地完成某件事情。比如在互斥部分的黄牛抢票代码,我们引入互斥锁确保了临界资源的数据安全,但是发现票被同一个线程全抢走了,而其他线程处于饥饿状态,这是因为这个线程刚释放锁,它对锁的竞争力比其他线程强,所以马上又能申请到锁,对此我们的解决办法是让每一个线程买到一张票释放锁后自己sleep等待几秒,让其他线程能够按照特定的顺序去买票,但这样的解决办法不提倡,因为sleep等待降低了多线程整体的运行效率,且在其它更为复杂的场景下sleep是不能解决同步问题的。
像上面这样因为时序问题,从而导致程序异常,我们称之为竞态条件。为了让多线程高效有序的同步运行,我们可以使用条件变量。
与互斥锁不同,同步条件变量是用来等待而不是上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止,通常条件变量和互斥锁同时使用。
条件变量可以使线程睡眠,等待某种条件出现,条件变量是线程间共享全局变量进行同步的一种机制。条件变量的本质是一个PCB等待队列,队列中存的是线程的PCB:
同步的前提是在保证数据安全的情况下,所以条件的检测是在互斥锁的保护下进行的。通常一个线程加锁后要对某条件变量进行条件判断,如果该条件为假,则线程自动阻塞(PCB放入条件等待队列),并释放互斥锁;如果另一个线程条件认为条件成熟了,它会发信号给关联的条件变量,唤醒一个或多个在该条件下等待的线程,这些线程重新申请互斥锁,重新评价条件。
条件变量采用的数据类型是pthread_cond_t
,该类型定义出来的变量在使用之前必须进行初始化,初始化包括以下两种方式:
PTHREAD_COND_INITIALIZER
赋值给静态分配的条件变量。
这两个函数成功返回0,失败则返回错误码。当pthread_cond_init函数的attr参数为NULL时,会创建一个默认属性的条件变量。
线程被唤醒之后:
这两个函数用于通知线程条件已经满足,向先线程或条件发送信号。必须注意的是,一定要在改变条件状态以后,再给线程发信号。pthread_cond_signal()激活一个等待该条件的线程,存在多个等待线程时,按入队顺序激活其中一个,而pthread_cond_broadcast()则激活所有等待线程。
我们在主线程中创建4个新线程,这4个新线程死循环地在条件变量里等待,主线程每输入一个回车字符就唤醒一个新线程:
#include
#include
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(),主线程中每输入一个字符就唤醒所有在条件变量下等待的线程:
编译运行: