# 并发缺陷
并发的缺陷主要分为死锁和非死锁缺陷。
# 非死锁缺陷
《操作系统导论》中基于 Lu 及其同事的研究,讨论了违反原子性缺陷和错误顺序缺陷。
- 违反原子性缺陷:
mysql 中出现过的例子:
Thread 1:: | |
if(thd->proc_info) { | |
//.... | |
fputs(thd->proc_info,..); | |
} | |
Thread 2:: | |
thd->proc_info = NULL; |
如果线程 1 在 fputs
前发生中断,线程 2 将 proc_info
设为空,再执行线程 1 就会出现引用空指针。通过加锁就可以解决该问题。
- 违反顺序缺陷:
Thread 1:: | |
void init() { | |
// ... | |
mThread = PR_CreateThred(mMain, ...); | |
} | |
Thread 2:: | |
void mMain(...) { | |
// ... | |
mState = mThread->State; | |
} |
线程 2 默认了 mState
已经初始化了,如果线程 1 没有先执行,就会引用空指针。所以需要强制顺序,即引入条件变量。
# 死锁缺陷
产生条件:
- 互斥:线程对于需要的资源进行互斥的访问。
- 持有并等待:线程持有资源,等待其他资源。
- 非抢占:线程获取的资源不能被抢占。
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这 个资源又是下一个线程要申请的。
总结一句话就是:死锁出现的条件就是两个线程各自持有某一资源的锁,然后再请求对方的资源。
当然可能不只是两个线程,多个线程且请求形成环即可。
要预防死锁,就需要避免出现死锁条件
# 循环等待
如果整个系统中,对于锁获得的顺序总是相同的,那么就不会出现循环等待。比如总共有两个锁 L1
和 L2
,如果每次都先申请 L1 再申请 L2,就不会出现死锁问题。这叫做全序。
全序很难做到,偏序可能更现实一点。也就是部分的锁的获取顺序是固定的。
但是其实设计都很复杂,需要很仔细。
当一个函数需要抢多个锁时,为了避免死锁问题而固定锁的顺序,有些人旋转根据锁的地址作为获取锁的顺序。
# 持有并等待
通过原子抢锁避免,就是在线程抢锁的过程中不会被打断。其实相当于就是在最外部加上一个全局锁。
lock(prevention); | |
lock(L1); | |
lock(L2); | |
//.... | |
unlock(prevention); |
# 非抢占
假设 AB 两个线程各自抢了 ab 锁,然后请求对方的锁,会造成死锁。死锁的原因之一就是 AB 都不会主动释放自己的锁。
在很多语言的线程库中都会提供一个 trylock()
函数,它会尝试抢锁,如果失败,不会阻塞而是返回 - 1,所以为了避免死锁问题,可以让线程抢对方的锁失败后主动释放自己的锁让对方抢:
top: | |
lock(L1); | |
if(trylock(L2) == -1) | |
unlock(L1); | |
goto top; |
但是也可能出现活锁,两个线程不断重复,都抢锁然后再释放锁,可以设置一个随机的等待时间,降低线程之间的相互干扰。
关于
trylock
的使用,较为麻烦的就是goto top
的实现,如果代码在中途中获取了某些资源,必须确保也能释放这些资源。
# 互斥
使资源的访问不再互斥,更多的是使用硬件支持,从而设计出无等待的数据结构。比如使用 CAS
设计链表插入:
void insert(int val) { | |
node_t *n = malloc(sizeof(node_t)); | |
assert(n != NULL); | |
n->val = val; | |
do{ | |
n->next = head; | |
} while(CompareAndSet(&head, n->next, n) == 0) | |
} |
# 基于事件的并发
这部分属于进阶内容,怎么说呢,如果你了解网络编程,如果你了解过 Netty,那么这部分理解起来就很简单。但是本部分要讲的内容也不会很多。
所以你听过
select()
和poll()
这两个 API 吗?
基于事件的服务器,最经典的设计就是写一个死循环,然后监听是否有事件,没有事件就会一直阻塞。 select/poll
就是用于支持检查是否有网络数据包到达。
int main(void) { | |
while(1) { | |
// ... | |
int rc = select(....); | |
// ... | |
} | |
} |
使用单个 CPU + 基于事件的应用程序就不会出现并发问题,因为一次只会处理一个事件(其实性能非常 low
,为了并发处理,也就有了 Netty
)。
当某个事件需要程序发出阻塞的系统调用,如从磁盘读文件,再返回给客户端。这样就需要发送 I/O
请求,造成阻塞,整个系统处于闲置状态,所以基于事件的系统(单线程)必须遵守:不允许调用阻塞调用。
许多现代操作系统都支持异步 I/O,当事件需要请求 I/O,异步 I/O 的接口使程序发送 I/O 请求,并且在 I/O 完成前将控制权立即返回给调用者,然后有其他接口轮询 I/O 是否完成。
随着客户端数量连接上升,单线程的基于事件的并发是不可能满足需求的,到时候依然需要,不可避免地创建新的线程来处理:事件,I/O 请求等,详细知识可以参考网络编程标签。
# 参考
https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/32.pdf
https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/33.pdf