Linux多线程服务端编程笔记 第二章

本文解决如下问题:

  1. 使用锁时要注意哪些问题。
  2. 死锁常见的两个例子以及如何避免死锁的两个简单方法。
  3. 条件变量的使用注意问题。
  4. 单例模式的问题与写法。
  5. 条件变量与锁的使用场景;
  6. 条件变量中的虚假唤醒原理是什么?
  7. 如何避免把类当做函数调用这种问题?
  8. 如何减少锁争用?(锁的延迟的主要占用点)

1. 使用锁时要注意哪些问题。

  1. 不直接使用std::mutex的lock和unlock函数,一切交给unique_lock等封装对象来完成;
  2. 使用unique_lock对象时,要考虑当前调用栈上是否已经持有了这个锁,避免死锁。
  3. 不使用跨进程的mutex,进程间通信只使用TCP sockets;
  4. 加锁和解锁必须在同一线程;(RAII自动保证)
  5. 不使用递归锁和读写锁、信号量。

对第五点的解释:读写锁在读优先的情况下会阻塞写操作,甚至会造成饥饿现象;写优先的情况下,会阻塞读,对读效率很敏感的程序来说很不友好。而普通锁在设计良好的情况下,临界区很小,效率是很高的,没有这些问题。
信号量的实现完全可以通过互斥器与条件变量来实现。另外信号量有自己的计数值,通常我们的数据结构也会保存一个计数值,两个计数值需要保持一致,增加了出错的可能。

2. 死锁常见的两个例子。

1. 同一线程发生死锁

同一个类中有锁的一个函数辗转调用了另一个有锁的函数。func1和func2调用时都持有锁,然后func2调用了func3,func3反过来调用了func1。调用关系如图1所示,此时存在死锁现象。

图1

func2直接调用func1,没有func3也会发生死锁。

2. 两个线程发生死锁

两个类中持有锁的两对函数相互调用。类1中包含func1,func2;类2中包含func3,func4,然后func1调用func3,func4调用func2。(反向相互调用)如图2所示。

图2

func1和func2可以重叠;func3和func4可以重叠。

3. 死锁的检测与预防

预防:

  1. 严格控制锁的调用顺序,用锁之前需要想调用栈上都有了哪些锁;
  2. 将锁内调用的函数同时定义一个无锁版,锁内调用那个无锁版;

检测:死锁之后,打开core文件或者使用如下gdb命令查看:

thread apply all bt

看到线程阻塞在一个锁上,或者某个非条件变量或者epoll_wait函数上,就是发生了死锁。

3. 条件变量的使用注意问题。

条件变量一般用于等待某个条件成立,即等待某个bool表达式为真。

  1. wait端
    (1)必须与mutex一起使用;wait函数和bool表达式的判断需要在mutex的保护下;
    (2)把bool表达式的判断和wait放到while循环中。
std::unique_lock lock(mtx);
while(queue.empty()) {
  cond.wait();
}
//在锁的保护下,从queue中获取变量。
  1. signal端
    (1)notify、notifyAll函数不一定在已上锁的情况下调用;
    (2)调用notify之前一般要修改bool表达式;(修改bool表达式要有锁保护)
std::unique_lock lock(mtx);
queue.push_back(x);
cond.notify();

4. 单例模式的问题与写法。

1. double check locking(DCL)实现单例模式的问题

DCL实现方法:

if(instance == null) {
    lock();
    if(instance == null) {
          instance = new Instance();
    }
}

在对instance的初始化的时候,编译器会按如下步骤:
(1)分配内存空间;
(2)初始化内存;
(3)将内存的地址赋值给instance。
编译器对(2)(3)步没有顺序限制。结果就是如果先(3),就会instance没有初始化就有值了,外部线程到了第一个if,检测有值,就去使用了,发生了未定义行为。

2. 使用static来完成单例模式
class SingleInstance {
private:
    
 public:
    get() {
        static instance;
        return instance;
    }
}
  1. 函数内的static对象是懒惰初始化,什么时候用什么时候初始化。
  2. 类静态变量的初始化则是饿汉初始化,即使不用类对象,也会在main函数开始执行之前进行初始化,不好。
  3. 除了使用函数内static初始化,还可以使用std::thread_once来保证只调用了一次。

5. 条件变量与锁的使用场景

  1. 锁是为了访问共享数据;
  2. 条件变量是为了等待事件发生。等待事件发生严禁使用sleep函数。
  3. 条件变量的notify通常代表资源可用(生产者模式);notifyall通常代表状态变化。(比如倒计时系统,可以开始做事了的那种)
    这两种用法都很常见。所以都要会。

6. 条件变量中的虚假唤醒原理是什么?

1. 基本原因

虚假唤醒是因为条件变量的wait函数调用的是futex系统调用,这个系统调用在阻塞状态下,被信号打断的时候会返回-1。即线程被唤醒了。

2. 验证

将一个线程阻塞在cond.wait()函数上,然后调用kill向这个线程发送信号,然后就会发现阻塞的线程被唤醒了。

3. 解决

虚假唤醒代表的是等待条件变量唤醒的线程可能不是因为bool表达式为正而唤醒,所以需要将wait放到一个循环中,而不是一个if中。
C++11提供的条件变量的wait函数可以使用第二个参数来实现自动while,这样就不用放到while循环了。

7. 如何避免把类当做函数调用这种问题?

比如定义了GuardLock类来封装std::mutex的使用。但是使用时不小心这样使用了:

GuardLock(std::mutex);

问题:
定义了一个临时变量,紧接着就析构掉了。锁没有加上。
解决:

#define GuardLock(x) static_assert("false", "hehe");

编译期找到这个错误。

8. 如何减少锁争用?(锁的延迟的主要占用点)

真正影响性能的不是锁,而是锁争用。

上一小节,有介绍如何减少锁争用:

  1. 对于拷贝代价比较小的共享变量来说:

读操作,临界区内拷贝出来,临界区外使用副本读取;
写操作,临界区外定义副本,完成要完成的操作,临界区内直接赋值或者swap;

读和写都使用副本,而不是使用共享变量,这样就不会出现竞争。当然,如果共享变量是指针,要拷贝的是指针指向的值。
问题:
多个进程的写操作还是会相互覆盖,且临界区定义副本,这个副本的初始值无法界定。所以这种方法不宜使用。(有缺点就不宜使用)

  1. 将无关操作移出临界区。

写操作时析构移除临界区:一般指的是shared_ptr在临界区内析构的问题

为了防止其在临界区内析构,我们可以使用栈上的临时变量来增加引用计数,然后在临界区外析构临时变量。

  1. 避免临界区内的循环。
    避免出现一边遍历一边决定是否对共享变量进行更改的情况;应该在临界区外遍历,然后记住该改哪些元素,最后在临界区内统一修改。

  2. shared_ptr来实现copy_on_write
    有几个条件:

(1)共享变量使用shared_ptr来管理。使用shared_ptr来管理其实和指针是一样的;
(2)读和写都要获取到锁,同一时间只能有一个线程拥有锁。copy_on_write的关键在于减少锁争用。
(3)读操作,放心读,抢到锁就读,但是需要在读之前定义一个临时变量,来增加引用计数;
(4)写操作,无法放心写,抢到锁之后,得先判断引用计数是否有别的线程在读,如果没有就可以直接写,如果有,为了减少写等待,需要深拷贝动作。

如图3所示:


屏幕快照 2018-09-13 下午11.57.41.png

读操作:
(1)抢到锁之后,先定义出来ptr1的副本ptr2,但是都是指向的共享变量,然后就可以退出临界区了。
(2)临界区外使用ptr2来访问临界区。因为ptr1可能被写。

(3)每个读都会创建一个ptr2副本,所以可能同一时间ptr的副本有超多个。
(4)可以在临界区外访问共享变量是因为只要有读,写不会改变这个共享变量,只会改变共享变量的副本。

写操作:
(1)抢到锁之后,判断有没有读(根据ptr的引用计数值是否为1);
(2)没有读的话,可以直接修改共享变量;
(3)有读的话,二话不说,把共享内存拷贝出来一份,然后在拷贝出来的内存中进行修改,最后把ptr3赋值给ptr。

(4)所有的写操作都要在临界区内部。
(5)如果这里的共享变量仍然是一个指针,拷贝共享变量使用深拷贝就可以。

问题:

  1. 由于存在拷贝操作,共享变量无法太大。
  2. 共享变量太大也可以,不要频繁写就可以。(比如一段时间更新一次数据)
  3. 如果 单线程写 && 写时获取到的是全部数据,(不是共享变量的一部分),则退化到第一点,无需拷贝,直接临界区内swap就可以了。

你可能感兴趣的:(Linux多线程服务端编程笔记 第二章)