背景:
进程中包括下面三个线程:
- 主线程main_thread,通过用户输入来执行相应的操作,包括:启动写数据线程,杀死写数据线程等;
- 定时器线程timer_thread,每两分钟写一次数据;
- 写数据线程write_thread,不停写数据。因为定时器线程和写数据线程会操作同一个全局变量,所以使用线程互斥锁进行了加锁操作。
三个线程的简单流程如下:
/* main thread */
int main()
{
......
switch (user_ops){
case 'w':
pthread_create(&write_thread,NULL....);
break;
case 'k':
pthread_cancel(&write_thread);
break;
}
......
}
/* write_thread */
{
......
pthread_mutex_lock(&entry_lock);
write_data();
update_entry();
pthread_mutex_unlock(&entry_lock);
......
}
/* timer_thread */
{
......
pthread_mutex_lock(&entry_lock);
write_entry_to_disk();
pthread_mutex_unlock(&entry_lock);
......
}
死锁现象
- 模拟用户操作,向main_thread中传入w命令,创建写数据线程;
- 写入一定量的数据之后,传入k命令,取消写数据线程;
- 在定时器到时,有一定的几率导致忙等。
原因分析
初步分析: 写数据线程对磁盘加锁后,还没有解锁便被取消掉了,同步线程在获取锁的地方忙等。
深入分析: man pthread_cancel 可以知道,调用 pthread_cancel 之后,具体会发生什么事情,是由两个状态决定的:cancelstate 和 canceltype。
- cancelstate 有 enabled 和 disabled 两个取值,当其值是 enable 的时候根据 canceltype 的值进行后续操作,否则将 cancel 请求加入到请求队列,等待 cancelstate 变为 enabled 的时候再进行后续操作。 默认值是 enabled。
- canceltype 也有两个取值: deferred 和 asynchronous,分别是延迟取消和异步取消。
延迟取消是指线程收到取消请求,并不作出任何响应,而是要到取消点(cancellation point)的时候才执行取消动作,取消动作包括:- 将 pthread_cleanup_push 压栈的函数出栈执行;
- 调用线程的析构函数;
- 调用 pthread_exit。
异步取消可以在任何时候执行取消动作。 线程的默认取消类型是延迟取消。
回到问题本身,write 系统调用刚好就是一个取消点(更多的取消点见《Unix环境高级编程》12.7章节),写数据进程有一定的概率在执行 write 系统调用的时候被取消掉,此时写数据进程还是持有锁的状态,从而造成定时器线程获取不到线程互斥锁而进行忙等。
解决方案
从线程被取消时执行的取消动作入手,在对线程互斥锁加锁前调用 pthread_cleanup_push ,将解锁操作压栈作为线程清理函数,在解锁后调用 pthread_cleanup_pop。 如下所示:
pthread_cleanup_push(pthread_mutex_unlock, (void *) &mutex);
pthread_mutex_lock(&mutex);
/* do some work */
pthread_mutex_unlock(&mutex);
pthread_cleanup_pop(0);
pthread_cleanup_pop 传入的参数为0,表示不执行出栈操作。如果线程被 pthread_cancel 取消时,会将压栈的清理函数全部出栈执行,这样就会对互斥量解锁, 如果正常退出则不执行。
扩展阅读
延伸到 linux 的另一种同步机制: 条件变量。pthread_cancel 遇到条件变量时隐藏了更加复杂的竞争问题, 同时了解 pthread_cond_wait 如何实现取消点。
条件变量需要配合互斥锁一起使用,其目的是为了“允许线程以无竞争的方式等待特定的条件发生”。 使用条件变量的情景如下:
pthread1:
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);
/* do something */
pthread_mutex_unlock(&mutex);
pthread2:
pthread_mutex_lock(&mutex);
/* do something to change the condition */
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mutex);
线程 1 对全局互斥量加锁,然后调用 pthread_cond_wait 函数等待条件发生,该函数也恰好是一个取消点。 pthread_cond_wait 以原子操作释放互斥量并且将线程放到等待条件的线程列表上,以保证解锁后不错过任何符合的条件, 然后进入睡眠,直到条件发生将其唤醒;
线程 2 获取全局互斥锁,进行一些操作导致条件发生,并调用 pthread_cond_broadcast 广播该条件,然后释放锁资源。
线程 1 被唤醒,重新获取互斥量(这个操作在pthread_cond_wait中发生),然后进行一些处理后释放锁资源。
(这种处理有点类似 DMA,不去询问是否有数据,而是数据来了通知其处理。)
重点是 pthread_cond_wait 函数,查看 glibc 源码:
..........
err = __pthread_mutex_unlock_usercnt (mutex, 0); /* 释放互斥锁 */
.........
pthread_cleanup_push (&buffer, condvar_cleanup, &cbuffer); /* 压栈了一个线程清理函数 */
..........
do{
.......
/* Enable asynchronous cancellation. Required by the standard. */
cbuffer.oldtype = __pthread_enable_asynccancel (); /* 将线程的取消类型设置为异步取消 */
/* Wait until woken by signal or broadcast. */
lll_futex_wait (&cond->__data.__futex, futex_val, pshared); /* 进入睡眠等待条件发生 */
/* Disable asynchronous cancellation. */ /* 将线程的取消类型修改回延迟取消 */
__pthread_disable_asynccancel (cbuffer.oldtype);
..........
}while......
压栈的线程清理函数 pthread_cleanup_push 做了很多东西我们都不管,最重要的是,最后一句: __pthread_mutex_cond_lock (cbuffer->mutex); 获取互斥锁。
获取互斥锁,是为了使得 pthread_cond_wait 调用前后的状态一致,同时还有 POSIX 一部分原因。
现在可以看出端倪了,如果在进入睡眠等待条件发生的时候, 该线程被其他线程 cancel 掉了,这是完全可能的,因为睡眠前设置了异步取消,那么 cancel 时会执行线程清理函数,重新对互斥量进行加锁。也就是说, 执行完 pthread_cancel 之后, 互斥锁是被持有的,并且持有它的线程已经退出了,导致其他要获取该互斥锁的线程进入忙等。
其实本质上和互斥量的情况是一样的,只是条件变量的情况稍微复杂了一点。解决办法也是一样的,在调用 pthread_mutex_lock 之前 调用 pthread_cleanup_push(pthread_mutex_unlock, &mutex), 调用 pthread_mutex_unlock 之后调用 pthread_cleanup_pull(0)。按照压栈的顺序,出栈时,先调用 pthread_cond_wait 中压入的 __condvar_cleanup 函数进行加锁,然后调用我们在 pthread_mutex_lock 前压入的 pthread_mutex_unlock 函数进行解锁, 这样 pthread_cancel 执行完之后,互斥量是没有被持有的。
上述内容如有整理不当或者错漏之处烦请指点。
参考资料:
《Unix环境高级编程》,linux manual page,glibc源码