线程同步和线程死锁
线程同步
前面刚介绍了有关线程的基本认识,那我们先来思考一个小问题,两个线程之间有没有可能同时对一个资源发起访问呢,答案是肯定,那么在某些情况下这样的同时访问会引发一系列冲突,先来看一个简单的例子。
创建两个线程,各自将count增加2500次,然后输出最后的结果,如下:
#include<stdio.h>
#include<pthread.h>
int count = 0;
void *thread_count(void *arg)
{
int i =0;
while(i<5000)
{
i++;
count++;
}
return NULL;
}
int main()
{
pthread_t id1,id2;
pthread_create(&id1,NULL,thread_count,NULL);
pthread_create(&id2,NULL,thread_count,NULL);
pthread_join(id1,NULL);
pthread_join(id2,NULL);
printf("count final val is : %d\n",count);
return 0;
}
毫无疑问,输出的结果是正确,那么我们来让两个线程一人增加50000000次,那么最后应该输出100000000。结果是不是这样呢?
结果是之前的猜想确实大相径庭。
我们先来分析一下问题产生的原因,前面之所以能够正确的打印出来10000,那是因为运算量过小,在一个线程运行的时候,由于时间过短,另一个线程没对它产生影响,而下面的100000000不能准确打印出来,就是因为在第一个线程运行尚未结束时,第二个线程也访问count,所以导致最后的结果是错的,所以我们看出,当程序运行越长,却容易发生这种访问冲突的问题。
那么这个问题怎么解决呢,那就是引入互斥锁,即所谓的同步机理就是通过互斥锁来实现的,说的通俗点就是,拿到锁的线程完成“读-修改-写”这样的操作,然后释放锁给其他线程,而没拿到锁的线程只能等待而不能对共享资源进行访问,这样的话“读-修改-写”三个操作组成了一个原子操作,要么都执行,要么都不执行。不会发生执行一半被打断的现象,这样的话就可以解决访问冲突的问题了。
初始化锁和销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
pthread_mutex_init函数对Mutex做初始化,参数attr设定Mutex的属性,如果attr为NULL则表示缺省参数。
pthread_mutex_init函数初始化的Mutex可以用pthread_mutex_destroy销毁,如果Mutex变量是静态分配的,也可以用宏定义PTHREAD_MUTEX_INITIALIZER 来初始化,相当于pthread_mutex_init初始化并且attr参数为NULL.
加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
一个线程可以通过调用lock()函数获得锁,而当这时其他线程已经先于它拿到锁时,这时该线程会呈挂起状态,一直等到另一个线程利用unlock()函数将锁释放该进程才被换唤醒,并且拿到锁之后才可以继续执行,如果既想获得锁,又不想挂起,那么就调用trylock()函数,如果这时锁被其他线程拿着,那么该进程会返回EBUSY,而不会是挂起状态。
那么现在讲刚才的代码稍加修改一下:
#include<stdio.h>
#include<pthread.h>
pthread_mutex_t mutex_lock = PTHREAD_MUTEX_INITIALIZER;
int count = 0;
void *thread_count(void *arg)
{
int i =0;
while(i<5000)
{
pthread_mutex_lock(&mutex_lock);
i++;
count++;
pthread_mutex_unlock(&mutex_lock);
}
return NULL;
}
int main()
{
pthread_t id1,id2;
pthread_create(&id1,NULL,thread_count,NULL);
pthread_create(&id2,NULL,thread_count,NULL);
pthread_join(id1,NULL);
pthread_join(id2,NULL);
printf("count final val is : %d\n",count);
return 0;
}
在每次count++之前加上一把锁,等加完之后再将锁释放,这样,最后的结果便会打印正确,避免的访问冲突的问题。
这里也可以将锁加在while()循环外面,也可以保证程序的正确性,两者有什么区别呢?
如果加在while()外面,相当于程序是一个串行执行,一个线程先加5000次,完了之后另一个线程再进行执行。
执行时间的话,因为加锁和解锁是有时间开销的,第一种写法每一次操作都需要加锁解锁,而第二种写法只进行一次加锁,所以就这一点来说第二种写法高效。
两者还有一个区别就是锁的粒度的不同,在一个程序中锁是一个非常拖慢性能的东西,所以尽可能的将锁的粒度减小。
总的来说还是第一种写法比较好,因为使用线程就是为了提高效率,保证程序的高并发执行。
补充:
对于上面两个线程同时对一个全局变量操作的问题,我在后续还遇到一些问题,下面就说一下。
起初我的虚拟机上的cpu是单核的。
查看cpu的核数
cat /proc/cpuinfo
在单核的情况下两个线程对这个全局变量单独各自加五千万次结果都是正确的,当各自加到一亿时,运行五次有三次都是正确结果。
接下来我将虚拟机的核数改为四核,cpu核数=处理器个数*每个处理器的内核数量
当我再次运行上面的代码时,两个线程各自加一亿,程序的运行时间比之前单核增加了许多。
并且这次当两个线程各自加五万时,结果都出现错误了。
总结:
- 首先我是在虚拟机上跑的,虚拟机本身对物理内存就有影响,正常单核情况下几乎不会出错。
- 当内核数变多时(这里用四个为例),四个内核相当于是并行运行的,提高了两个线程同时对count进行++的可能。
优化:
这里是老师给我提了一下,如果是他的话,这里不会用锁去实现,因为多个线程每次都在申请锁,释放锁,等待锁….,整个程序的大部分时间都耗费在这里了。
可以让两个线程各自单独的运行这段代码,最后再把整个程序的结果放入这个全局变量。写这段代码主要就是为了引入线程同步互斥这个概念,不过老师说这一点也算是让我了解一下处理问题的一些思路,就先补充这些。
线程死锁
我们试想下面这样一种情景,假如有2个线程,一个线程想先锁对象1,再锁对象2,恰好另外有一个线程先锁对象2,再锁对象1。
在这个过程中,当线程1把对象1锁好以后,就想去锁对象2,但是不巧,线程2已经把对象2锁上了,也正在尝试去锁对象1。
什么时候结束呢,只有线程1把2个对象都锁上并把方法执行完,并且线程2把2个对象也都锁上并且把方法执行完毕,那么就结束了,但是,谁都不肯放掉已经锁上的对象,所以就没有结果,这种情况就叫做线程死锁。
所以我们在写程序的时候,应当尽量避免同时获得多个锁,如果非得这样的话,则有一个原则:如果所有线程在需要多个锁时,都按照相同的先后顺序获得锁,则不会出现死锁,就是当一个线程拿到第一个锁所,那么它必定是可以拿到后面的锁的。