二元信号量提供了一种很方便的方法来确保对共享变量的互斥访问,即每个共享变量与一个信号量 s(初始为1)联系起来,然后P(s)和V(s)操作将相应的临界区包围起来。互斥锁是以提供互斥为目的的二元信号量,二者均属于挂起等待锁。
互斥锁(针对于线程)
互斥锁用于确保同一时间只有一个线程能访问被互斥锁保护的资源(互斥锁是针对于线程的)。锁定互斥量的线程与解锁互斥量的线程必须是同一个线程。
互斥锁的引入必须对互斥量mutex有一个新的认识,多个线程同时访问共享数据时可能会发生冲突,这与信号的可重入性是同样的问题。比如两个线程要把某个全局变量加1,这个操作在Linux下需要三条指令完成:
(1)从内存读变量到寄存器;
(2)寄存器值加1;
(3)将寄存器的值写回内存。
我们在读取变量的值和把变量的新值保存回去两步操作之间插入一个 printf 调用,它会执行 write 系统调用进内核,为内核调用别的线程执行提供一个很好的时机(线程与进程间切换最好的时机:从内核态切换到用户态)。我们在一个循环中重复上述操作几千次,就会出现访问冲突的现象。
#include<stdio.h> #include<pthread.h> #include<sys/types.h> #include<unistd.h> int count = 0; void *pthread_run(void* arg) { int val = 0; int i = 0; while(i < 5000) { i++; val = count; printf("pthread:%lu,count:%d\n",pthread_self(),count); count = val + 1; } return NULL; } int main() { pthread_t pth1; pthread_t pth2; pthread_create(&pth1, NULL, &pthread_run, NULL); pthread_create(&pth2, NULL, &pthread_run, NULL); pthread_join(pth1, NULL); pthread_join(pth2, NULL); printf("count:%d\n",count); return 0; }运行结果如下图所示:
上述程序的运行结果足以说明一切,正确结果应该是10000,程序在用户态与内核态之间不断的切换,两个线程对其进行访问(因为i++不是原子的),则必然导致不可预料的结果。
对于多线程的访问,访问冲突的问题是非常普遍的,解决的方法就是加入互斥锁,获得锁的线程可以完成“读-修改-写”的操作,然后释放了锁给其他线程,没有获得锁的线程只能等待而不能访问共享数据,这样“读-修改-写”三步操作组成了一个原子操作。
如果mutex_enter不能设置锁(因为另外一个进程已经设置了),则阻塞动作将取决于互斥对象中保存的专用类型信息。与互斥锁相关联的操作如下所示:
int pthread_mutex_init(pthread_mutex_t * mutex , pthread_mutexattr_t * attr); int pthread_mutex_destroy (pthread_mutex_t * mutex); int pthread_mutex_lock (pthread_mutex_t * mutex ); int pthread_mutex_unlock (pthread_mutex_t * mutex ); int pthread_mutex_trylock (pthread_mutex_t * mutex );
(1)pthread_mutex_init:初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
参数mutex:互斥锁地址,类型为 pthread_mutex_t;
参数attr:设置互斥量的属性,通常采用默认属性设置为NULL;
如果mutex变量是静态分配的(全局变量或static变量),也可以用宏定义PTHREAD_MUTEX_INITIALIZER来初始化,相当于pthread_mutex_init初始化并且attr设置为NULL。
返回值:成功返回0,失败返回错误码。
(2)pthread_mutex_destroy:销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
直接加互斥锁的地址进行销毁。
(3)pthread_mutex_lock:加锁,挂起等待锁(与二元信号量一样)
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数mutex:互斥锁的地址;
返回值:成功返回0,失败返回错误码。
一个线程可以调用此函数来对mutex加锁,如果另外一个线程想获得mutex,则只能挂起等待,直到当前线程释放锁为止。
挂起等待在严格意义上是:每个mutex有一个等待队列,一个线程在mutex上等待挂起,首先要将自己的加入到等待队列里,本质上是将PCB加入等待队列中。
(4)pthread_mutex_unlock:解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex:互斥锁地址;
返回值:成功返回0,失败返回错误码。
使用此函数释放mutex,当前线程将被唤醒,才能获得该mutex并继续执行。
(5)pthread_mutex_trylock:加锁,尝试失败后立即返回,不等待的非阻塞式
int pthread_mutex_trylock(pthread_mutex_t *mutex);
如果一个线程既想得到锁,又不想挂起等待,可以调用 trylock,如果mutex已经被另外一个线程获得,这个函数会失败返回EBUSY,而不会使线程挂起等待。
互斥锁测试
注意:pthread不是Linux下的默认的库,也就是说在链接的时候无法找到pthread库中各个函数的入口地址,于是会链接失败,所以在编译的时候要加上 -lpthread 参数。
#include<stdio.h> #include<pthread.h> #include<sys/types.h> #include<unistd.h> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int count = 0; void *pthread_run(void* arg) { int val = 0; int i = 0; while(i < 5000) { //对临界区加锁 pthread_mutex_lock(&mutex); i++; val = count; printf("pthread:%lu,count:%d\n",pthread_self(),count); count = val + 1; //解锁操作 pthread_mutex_unlock(&mutex); } return NULL; } int main() { pthread_t pth1; pthread_t pth2; pthread_create(&pth1, NULL, &pthread_run, NULL); pthread_create(&pth2, NULL, &pthread_run, NULL); pthread_join(pth1, NULL); pthread_join(pth2, NULL); printf("count:%d\n",count); return 0; }运行结果如下图所示:
当我们对临界区加锁之后,程序的运行结果符合我们的预期,多个线程访问临界资源不会再发生冲突。
死锁的原理
信号量潜在的引入了令人厌恶的运行时错误,称之为死锁(deadlock),它指的是一组线程被阻塞了,等待一个永远也不会为真的条件,它是一组相互竞争系统资源或进程通信的进程间的“永久”阻塞。
下面展示了一对用两个信号量来实现互斥的线程的进度图:
线程1申请到 s,线程2申请到 t,由于继续执行时,线程1阻塞在 t 上,而线程2阻塞在 s 上,因而形成了死锁。
(1)使用PV操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨线恰巧到达死锁区域d,则不可能继续发展。也验证了死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的V操作。
(2)重叠的禁止区域引入了一组称为死锁区域(deadlock regin)的状态。轨迹线一旦进入死锁区域,那么死锁则必然发生。
互斥锁加锁顺序规则:如果对于程序中每对互斥锁(s,t),给出所有的锁分配的一个详细使用铨叙,每个线程按照这个顺序来申请锁,并且按照逆序来释放,那么这个程序就是无死锁的。
死锁的条件
死锁的四个必要条件:
(1)互斥,一次只有一个进程可以使用一个资源,其他进程不能访问已分配给其他进程的资源。
(2)占有且等待,当一个进程等待其他进程时,继续占有已经分配的资源。
(3)不可抢占,不能强行抢占进程已占有的资源。
(4)循环等待,存在一个封闭的进程链,使得每个进程至少占有此链中下一个进程所需要的一个资源。
死锁预防
(1)破坏互斥条件:一般来说,这个条件是不可能禁止的,如果需要对资源进行互斥访问,那么操作系统必须支持互斥。
(2)资源一次性分配:可以要求进程一次性请求所有需要的资源,并且阻塞这个进程直到所有请求同时满足。
但是存在三个问题:
① 一个进程可能被阻塞很长时间以等待所有资源,但其他进程可能只需要一部分资源就可以运行。
② 分配给一个进程的资源可能有很长一段时间没有被使用,在此期间,也不会被其他进程使用。
③ 一个进程可能事先不知道他说需要的全部资源。
(3)破坏不可抢占条件:但是这种只能是资源状态可以很容易保存和恢复的前提下才是实用的。
(4)资源的有序分配:对资源按照序号进行申请锁,确保锁的分配顺序可以预防死锁。但是它可能是低效的,它会使进程执行速度变慢,并且可能在没有必要的情况下拒绝资源访问。
避免死锁的两个方法:
(1)如果一个进程的请求会导致死锁,则不启动此进程。
(2)如果一个进程增加的资源请求会导致死锁,则不允许此分配。
银行家算法:资源分配拒绝策略,又称为银行家算法,首次提出了安全状态,即至少有一个资源分配序列不会导致死锁(即所有进程都能运行直到结束)。
讲到这里死锁问题也就阐述完结,不知道大家是否对死锁有了新的认识,感兴趣的可以编写一下银行家算法,当然还有重要的哲学家就餐问题,这里不再做详细阐述。