# 并发缺陷

并发的缺陷主要分为死锁和非死锁缺陷。

# 非死锁缺陷

《操作系统导论》中基于 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 没有先执行,就会引用空指针。所以需要强制顺序,即引入条件变量。

# 死锁缺陷

产生条件:

  • 互斥:线程对于需要的资源进行互斥的访问。
  • 持有并等待:线程持有资源,等待其他资源。
  • 非抢占:线程获取的资源不能被抢占。
  • 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这 个资源又是下一个线程要申请的。

总结一句话就是:死锁出现的条件就是两个线程各自持有某一资源的锁,然后再请求对方的资源。

当然可能不只是两个线程,多个线程且请求形成环即可。

要预防死锁,就需要避免出现死锁条件

# 循环等待

如果整个系统中,对于锁获得的顺序总是相同的,那么就不会出现循环等待。比如总共有两个锁 L1L2 ,如果每次都先申请 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