第二章:线程同步精要

线程同步的四项原则

1、 首要原则是尽量最低限度的共享对象,减少同步的场合,对象尽量不暴露给别的线程,如果要暴露,首选考虑暴露immutable对象
2、 其次是使用高级构件,如:TaskQueue、Producer-Consumer Queue、CountDownLatch等等
3、 最后不得已必须使用底层同步原语,只用非递归的互斥器和条件变量,慎用读写锁,不用信号量
4、 除了使用atomic整数外,不自己编写lock-free代码,也不要使用“内核级”同步原语
本文解决如下问题:

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

使用锁时要注意哪些问题

  1. 不直接使用std::mutex的lock和unlock函数,一切交给unique_lock等封装对象来完成
  2. 使用unique_lock对象时,要考虑当前调用栈上是否已经持有了这个锁,避免死锁。
  3. 不使用跨进程的mutex,进程间通信只使用TCP sockets;
  4. 加锁和解锁必须在同一线程;(RAII自动保证)
  5. 不使用递归锁和读写锁、信号量。
    **对第五点的解释:**读写锁在读优先的情况下会阻塞写操作,甚至会造成饥饿现象;写优先的情况下,会阻塞读,对读效率很敏感的程序来说很不友好。而普通锁在设计良好的情况下,临界区很小,效率是很高的,没有这些问题。
    信号量的实现完全可以通过互斥器与条件变量来实现。另外信号量有自己的计数值,通常我们的数据结构也会保存一个计数值,两个计数值需要保持一致,增加了出错的可能。

死锁常见的两个例子

  1. 同一线程发生死锁
    图1func2直接调用func1,没有func3也会发生死锁。

  2. 两个线程发生死锁
    **两个类中持有锁的两对函数相互调用。**类1中包含func1,func2;类2中包含func3,func4,然后func1调用func3,func4调用func2。(反向相互调用)如图2所示。
    图2func1和func2可以重叠;func3和func4可以重叠。

  3. 死锁的检测与预防
    预防:
    严格控制锁的调用顺序,用锁之前需要想调用栈上都有了哪些锁;
    将锁内调用的函数同时定义一个无锁版,锁内调用那个无锁版;

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

thread apply all bt

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

条件变量的使用注意问题

条件变量一般用于等待某个条件成立,即等待某个bool表达式为真。
wait端
(1)必须与mutex一起使用;wait函数和bool表达式的判断需要在mutex的保护下;
(2)把bool表达式的判断和wait放到while循环中。

std::unique_lock<std::mutex> lock(mtx);
while(queue.empty()) {
  cond.wait();
}
// 在锁的保护下,从queue中获取变量。
// 为了防止条件变量被虚假唤醒

signal端
(1)notify、notifyAll函数不一定在已上锁的情况下调用;
(2)调用notify之前一般要修改bool表达式;(修改bool表达式要有锁保护)

std::unique_lock<std::mutex> lock(mtx);
queue.push_back(x);
cond.notify();

条件变量是非常底层的同步原语,很少直接使用,一般 都是用它来实现高层的同步措施,如 BlockingQueue
或 CountDownLatch(倒计时)。
倒计时是一种常用且易用的同步手段,它主要有两种用途:

主线程发起多个子线程,等待这些子线程各自都完成 一定任务 之后,主线程才继续执行。通常用于主线程等待多个子线程完成初始化。

主线程发起多个子线程,子线程都等待主线程,主线程完成其他一些任务之后通知所有子线程开始执行。通常用于多个子线程等待主线程发出 “起跑” 命令。

倒计时的代码:

/*************************************************************************
    > File Name: CountDownLatch.cpp
    > Author: Nfh
    > Mail: 1024222310@qq.com 
    > Created Time: 2020年07月07日 星期二 10时55分38秒
 ************************************************************************/

#include <iostream>
#include <mutex>
#include <condition_variable>

// 倒计时类
class CountDownLatch
{
    public:
	    // 倒数几次
        explicit CountDownLatch(int count) : count_(count){}

	    // 等待计数值变为0
        void wait()
        {
            std::unique_lock<std::mutex> guard(mutex_);
            while(count_ > 0)
                condition_.wait(guard);
        }
	    // 计数减一                         
        void countDown()
        {
            std::lock_guard<std::mutex> guard(mutex_);
            --count_;
            if(count_ == 0)
                condition_.notify_all();
        }

    private:
        int count_;
        mutable std::mutex mutex_;
        std::condition_variable condition_;
};

int main()
{
    return 0;
}

单例模式的问题与写法

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;
}
}
函数内的static对象是懒惰初始化,什么时候用什么时候初始化。
类静态变量的初始化则是饿汉初始化,即使不用类对象,也会在main函数开始执行之前进行初始化,不好。
除了使用函数内static初始化,还可以使用std::thread_once来保证只调用了一次。

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

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

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

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

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

MutexLock、MutexLockGuard的实现

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

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

GuardLock(std::mutex);

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

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

编译期找到这个错误。

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

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

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

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

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

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

将无关操作移出临界区。

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

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

  1. 避免临界区内的循环。

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

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

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

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

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

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

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

四、多线程服务器的适用场合与常用编程模型

4.1 单线程服务器常用的编程模型
1、非阻塞I/O + I/O复用(non-blockingIO + IO multiplexing)即Reactor模式。

2、Reactor模型的优点很明显,编程不难,效率也不错。不仅可以用于读写socket,连接的建立(connect(2) / accept(2) )甚至DNS解析都可以用非阻塞的方式进行,以提高并发度和吞吐量(throughput),对IO密集型 的应用是不错的选择 。

3、Reactor模式适合于IO密集型的应用。

4.2 多线程服务器的常用编程模型
1、one (event)loop per thread + threadpool(一个事件循环一个线程 + 线程池)

  • eventloop(也叫IO loop)用作IO multiplexing,配合non-blocking IO 和定时器。
  • threadpool 用来做计算,具体可以是任务队列或者生产者消费者队列。

2、这个方式的好处是:

  • 线程的数目基本固定,可以在程序启动时设置,不会频繁创建和销毁。
  • 可以很方便的在线程间调配负载。
  • IO事件发生的线程是固定的,同一个TCP连接不必考虑事件 并发。

3、大致介绍

  • Eventloop代表了线程的主循环,需要 让哪个线程干活,就把timer或 IO
    channel(如TCP连接)注册到哪个线程的loop里即可;
  • 对实时性有要求的connection可以单独用一个线程;
  • 数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个计算线程中(线程池);
  • 其他次要的辅助性connections可以共享一个程序。
  • 对于non-trivial的服务端程序,一般会采用non-blocking IO + IO multiplexing,每个
    connection/acceptor都会注册到某个
    eventloop上,程序里有多个eventloop,每个线程至多一个eventloop。
  • 多线程程序对eventloop提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的loop里塞东西,这个loop必须是线程安全的。
  • 程序里有几个loop、线程池的大小等参数需要根据应用来设定,基本原则是 ”阻抗匹配“,使得CPU和IO都能高效的运作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值