多线程之线程同步

线程同步

  • 哪些情况下可能出错:
    • 未同步化的数据访问:并行运行的两个线程读和写同一笔数据,不知道哪一个语句先来
    • 写至半途的数据:某个线程正在读数据,另一个线程改动它,于是读取中的线程可能读到改了一半的数据,读到一个半新半旧值
    • 重新安排的语句:语句和操作有可能重新安排次序,因为C++只要求编译所得的代码在单一线程内的可观测行为正确,所以很有可能重新安排数据,只要单一线程的可视效果相同
  • 解决问题所需要的性质:
    • 不可切割性:读写一个变量或语句,其行为是独占的
    • 次序:保证按具体指定语句次序执行
    • 解决方法(由高级到低级排序):
      • future和promise都保证不可切割性和次序:一定是在形成结果之后才设定shared state
      • mutex和lock:授予独占权
  • 条件变量:
  • 原子操作
    • 原子操作的底层接口:它允许放宽atomic语句的次序或针对内存访问使用手指藩篱——待验证

互斥量

mutex和lock:独占式访问资源
* mutex:同一时间只可以被一个线程锁定,同一个锁多次锁定会造成死锁;通过成员函数lock()进行上锁,unlokc进行解锁
* recursive_mutex: 允许同一时间多次被同一线程获得其lock,允许同一线程多次锁定,并在最近一次相应的unlock时释放lock
* time_mutex:额外允许传递一个时间段或时间点,用来定义多长时间内它可以尝试捕捉一个lock;为此它提供了try_lock_for()和try_lock_until()
* recursive_timed_mutex:允许同一线程多次取得lock,可以指定期限
* try_lock():想获得一个lock,但不成功的话不想阻塞;该函数成功锁定返回true,否则返回false
* 为了能够使用lock_guard,可以传一个额外实参adopt_lock给其构造函数,注意:
+ try_lock有可能假失败,即lock并未被拿走但也有可能失败
+ 不要将受保护数据的指针或引用

  • 为了等待特定长度的时间,可以使用time mutex: timed_mutex和resursive_timed_mutex,允许调用try_lock_for和try_lock_until,用以等待某个时间段或达到某个时间点
  • recursive_mutex:嵌套锁
  • 尝试性lock:尝试获得一个lock,如果不能成功的话不想被永远阻塞住,try_lock,为了能够使用lock_guard,可以传递一个额外实参adopt_lock给构造函数
  • 异常:
  • 第二次lock抛出异常std::system_error,并带差错码resource_deadlock_would_occur
    • lock:可以一次性锁住多个互斥量,并且没有副作用
    • lock_guard<…>(…):
    • unique_lock<>():

lock_guard和unique_lock

  • 优点:析构时mutex被锁住其析构会自动调用unlock(),如果没有锁住mutex则析构不做任何事
  • 与lock_guard相比,unique_lock添加了三个构造:
    * try_to_lock:企图锁住mutex但不希望被锁住 unique_lock lock(mutex, std::try_to_lock)
    * 传递一个时间点或时间段给构造,尝试在一个明确的时间周期内锁定 unique_lock<timed_mutex> lock(mutex, std::chrono::seconds(1))
    * 传递defer_lock,表示初始化这一个lock object但尚未打算被锁住mutex unique_lock
    * 此外,unique_lock提供release来释放mutex,或将mutex的拥有权转移给另一个lock
  • unique_lock和条件变量配合使用,先用lock锁住,接着条件变量的wait会去检查这些条件,当条件满足时返回,如果条件不满足,wait会解锁互斥量,将当前线程置于等待状态;当准备数据的线程调用notify_one/norify_all通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥量并且对条件再次检查
  • 使用心得:
    • 单独使用时unique_lock和lock_guard一样都是加锁,释放锁,并不存在unique_lock更消耗资源(只是多一个bool字段)。unique_lock可以配合条件变量使用
    • wait( std::unique_lockstd::mutex& lock )函数源码跳转不进去
  • call_once:第一实参必须是相应的once_flag,确保传入的机能只被执行一次,下一实参是可调用对象;比起锁住互斥量,并显示的检查指针,每个线程只需要使用是他的std::call_once,在std::call_once结束时,就能安全的知道指针已经被其他的线程初始化了。使用是std::call_once比显示使用互斥量消耗的资源更少

条件变量

#include<condition_variable>,condition_variable和condition_variable_any

  • 运作如下:
    a. 必须#include, #include<condition_variable>, 并声明一个mutex和一个condition_variable
    b. 激发条件终于满足的线程必须调用notify_one或notify_all
    c. 等待的线程必须调用std::unique_lock l(readyMutex); readyCondVar.wait(1)
    d. condition_variable:仅限于与std::mutex一起工作,多个线程可以等待某特定条件发生,一旦条件满足,如果无法建立condition_variable,构造函数会抛出std::system_error异常
  • 方法:
    • wait(ul)/wait(ul, pred):使用unique lock来等待通知/直到pred在一次苏醒之后结果为true。等待条件被满足的线程必须调用wait;wait内部会明确对mutex进行解锁和锁定
    • wait_for(ul, duration) / wait_for(ul, duration, pred):使用unique lock ul来等待通知,等待期限是duration/或知道pred在一次苏醒之后结果为true
    • wait_until(ul, timepoint)/wait_until(ul, timepoint, pred):使用unique lock ul来等待通知,直到时间点timepoint/或直到pred在一次苏醒之后结果为true
    • notify_one()/notify_all():激发条件满足的线程必须调用notify_one或notify_all
  • 注意:condition变量有可能有假醒,也就是wait动作有可能在condition尚未被notified时便返回所以在唤醒之后还要验证条件是否已达成
  • condition_variable_any:可以和任何满足最低标准的互斥量一起工作,但是会产生额外的开销

原子操作

#include,原子操作是一类不可分割的操作,不可以再分解为基本类型,包括整型,实型等。当这样的操作在进行到一半的时候,你是不能查看的,它的状态要不是完成,要不就是未完成。

  • std::atomic_flag:简单的布尔标识,在这种类型的操作上都需要是无锁的。可以在两个状态之间进行切换:设置和清除
    • 特点:相比其他atomic类型,atomic_flag是无锁的;并且不提供is_lock_free()成员函数;atomic不是无锁的,未来保证操作的原子性,其实现中需要一个内置的互斥量
    • 每一个原子操作默认的内存顺序都是memory_order_seq_cst
    • 缺陷:这个方法局限性强,没有非修改查询操作,不能像bool标识那样使用,所以最好使用std::atomic
    • 初始化:首次使用时必须被值ATOMIC_FLAG_INIT初始化,表示清除状态,即false
    • 不能拷贝构造一个std::atomic_flag对象,并且也不能将一个对象赋予另一个std::atomic_flag对象,这不是atomic_flag特有的,而是所有原子类型共有的
    • 当标志对象已初始化,只能做三件事:销毁,清除,设置
    • 方法:
      • 销毁:clear()
      • 查询或设置:test_and_set()
      • atomic_flag非常适合于做自旋锁
  • 自旋锁:
    • 与互斥锁相比,自旋锁在获取锁的时候不会使得线程阻塞而是一直自旋尝试获取锁,当线程等待自旋锁的时候CPU不能做其他事情,而是一直处于忙等待状态
    • 主要适用场景:主要适用于被持有时间短,线程不希望在重新调度上花过多时间的情况
    • 使用自旋锁要注意:由于自旋时不释放CPU,如果在持锁时间很长的场景下使用自旋锁,会导致CPU在这个线程的时间片用尽之前一直消耗在无意义的忙等上,造成浪费;因而持有自旋锁的线程应该尽快释放自旋锁
      std::atomic:原子类型只能从模板参数中构造,不允许拷贝构造和拷贝赋值,移动构造,不过从atomic类型的变量来构造其模板参数T的变量则是可以的。
      总是应该将atomic对象初始化,因为默认构造函数不一定完全初始化它(倒不是初值不明确,而是其lock未被初始化)。如果只使用default构造函数,接下来唯一合法的操作是调用:(静态变量怎样初始化?)
      std::atomic readyFlag;
      std::atomic_init(&readyFlag, false);
  • 方法:
    • a.store(val):赋予一个新值val并返回void
    • a.load():返回数值a的拷贝
    • a.exchange(val):交换val并返回旧值a的拷贝
    • atomic a; atomic_init(&a, val):初始化a
    • a.is_lock_free():如果内部不使用lock便返回true——具体作用?只有std::atomic_flag类型不提供is_lock_free()成员函数
    • a.compare_exchange_strong(exp, des):这个两个函数具体不清楚是干啥的
    • a.compare_exchange_weak(exp, des)
    • a.fetch_add(val):不可切割之t+=val?,返回新值拷贝
    • a.fetch_sub(val)
    • a+=val / a-=val
    • a++/++a/a–/–a
    • a.fetch_and(val)/a.fetch_or(val)/a.fetch_xor(val)
    • a&=val/a|=val/a^=val

模板std::atomic<>

存在的目的是除了标准原子类型外,允许用户使用自定义类型创建一个原子变量。

  • 自定义类型使用该模板的要求:自定义类型是不是要求是POD类型
  • 必须有拷贝赋值运算符,这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作—?
  • 这个类型必须是位可比的,可以使用memcpy进行拷贝,还需要确定其对象可以使用memcmp对位进行比较,之所以这么要求是为了保证比较交换操作能正常工作
  • 通常情况下编译器不会为std::atomic<>类型生成无锁代码,所以他将对所有操作使用一个内部锁
  • atomic的C风格接口:对C标准的扩充
    • atomic_init
    • atomic_store
    • atomic_load
  • atomic低层接口:意味着使用atomic操作时不保证顺序一致性,

内存模型

原子操作的内存顺序:默认memory_order_seq_cst,代表三种内存模型:

  • 排序一致序列:
  • memory_order_seq_cst
  • 获取-释放序列:原子加载(memory_order_acquire)就是获取操作,原子存储(memory_order_release)就是释放操作,在这里不是获取就是释放,或者两者兼有操作(memory_order_acq_rel)
memory_order_consume
memory_order_acquire:原子加载
memory_order_release:原子存储
memory_order_acq_rel
  • 自由序列:没怎么懂
    memory_order_relaxed

并发数据结构的设计

需要思考,如何让序列化访问最小化,让真实并发最大化:

  1. 锁范围中的操作,是否允许在锁外执行?
  2. 数据结构中不同的区域能否被不同的互斥量所保护?
  3. 所有操作都需要同级互斥量保护吗
    基于锁的并发数据结构设计,需要确保访问线程持有锁的时间最短
    可能出现的问题:
  4. 无意中传递了保护数据的引用:不要将受保护数据的指针或引用传递到互斥锁之外,如通过返回值或参数形式传递到外面——C++并发编程57
  5. 发生在接口上的条件竞争:C++并发编程60
    C++11中最重要的新特性之一,是多线程感知的内存模型
    内存模型:通常是一个硬件上的概念,表示的是机器指令是以什么样的顺序被处理器执行的
    • 强顺序:按顺序执行,X86是强顺序模型
    • 弱顺序:不按顺序执行,可以使指令执行的性能更高
    顺序一致性:
    • 在C++11中原子类型的成员函数总是保证了顺序一致性,对于x86平台来说禁止了编译器对原子类型变量间的重排优化,默认memory_order_seq_cst。C++11中设计者给出的解决方式是让程序员为原子操作指定所谓的内存顺序:memory_order
    • memory_order_relaxed:自由序列,没有任何同步关系
    • memory_order_acquired:本线程中,所有后续的操作
    • memory_order_release:
    • memory_order_acq_rel:
    • memory_order_consume:
    • memory_order_seq_cst:全部都按顺序执行:序列一致是最简单,最直观的序列,但也是最昂贵的内存序列,因为需要对所有线程进行全局同步

无锁数据结构

volatile:用来提供过度优化,既不保证不可切割,也不保证次序
terminate函数在默认情况下是去调用abort函数的,不过用户可以通过set_terminate函数来改变默认行为
abort函数更加底层,abort不会调用任何析构
exit属于正常退出,会调用自动变量的析构函数,并且还会调用atexit注册的函数,这和main函数结束时的清理工作是一样的
C++11标准引入快速退出:quick_exit,at_quick_exit

猜你喜欢

转载自blog.csdn.net/u010378559/article/details/131590261