[译]GotW #95 Solution: Thread Safety and Synchronization
原文地址: GotW #95 Solution: Thread Safety and Synchronization
这篇 GotW 是来回答一些关于线程安全与同步的问题的. 我们的讨论几乎适用于所有主流语言
问题
JG 问题
1) 竞态条件(race condition)指的是什么? 它有很严重吗?
2) 什么是正确同步的程序? 你是如何实现的? 请具体说明.
Guru 问题
3) 考虑下面的代码, some_obj
是一个多个线程可见的共享变量.
1 2 3 4 5 |
|
如果线程 1 与线程 2 能够并行, 那么当 some_ojb
是如下类型时, 代码是否能够正确同步?
- a)
int
- b)
string
- c)
vector<map<int,string>>
- d)
shared_ptr<widget>
- e)
mutex
- f)
condition_variable
- g)
atomic<unsigned>
提示: 虽然有 7 个类型, 但实际上答案只有两种.
4) 外部同步, 意味着使用共享对象的代码需要自己来保证对象的同步. 回答下面有关外部同步的问题:
- a) 一般的外部同步的职责是什么?
- b) 什么是”基本的线程安全保障”?
- c) 哪些内部同步是在共享变量的实现中需要做的?
5) 完全的内部同步类型(线程安全类型), 意味着所有的同步在对象内部完成, 外部不需要再进行同步. 哪些类型是内部同步的, 为什么?
解决方案
1) 竞态条件(race condition)指的是什么? 它有很严重吗?
当两个线程同时访问一个共享变量时, 并且至少有一个是 non-const 操作(写操作)时会发生竞态条件(race condition). 并发的 const 操作是允许的, 不会发生竞态条件.
如果发生了竞态条件, 那么你的程序会产生未定义行为 (undefined behavior).
准则: 针对共享变量的只读 (const) 操作, 在没有外部同步的情况下也是安全的.
2) 什么是正确同步的程序? 你是如何实现的? 请具体说明.
正确同步的程序指的是没有竞态条件的程序.
共享变量通常的保护方式有:
- 通常使用
mutex
或其他类似的东西; - 极少情况下使用
atomic
; - 极少情况下确认是内部同步的类型, 下面会讲到.
3) 考虑下面的代码, some_obj
是一个多个线程可见的共享变量.
1 2 3 4 5 |
|
如果线程 1 与线程 2 能够并行, 那么当 some_ojb
是如下类型时, 代码是否能够正确同步?
- a)
int
- b)
string
- c)
vector<map<int,string>>
- d)
shared_ptr<widget>
不能. 代码中有一个线程对 some_obj 做读操作(const 操作), 而另一个线程对 some_obj
进行写操作. 如果这两个线程同时执行, 那么就有可能发生竞态条件.
要正确同步, 需要对 some_obj
的访问进行同步, 比如使用 mutex
:
1 2 3 4 5 6 7 8 9 10 11 |
|
几乎所有类型, 包括 shared_ptr
以及 vector
以及其他类型, 他们的线程安全级别与 int
是一样的. 它们没有特别的为并行设计. 无论 some_obj
是一个 int
, string
, 容器, 还是智能指针类型, 并发读 (const 操作) 不需要同步也是安全的, 但是共享变量是可写的, 因此使用该变量的代码需要同步访问.
但是, 上面说的是 “几乎所有类型”, 指的是不包含内部同步的类, 那些类型设计的时候就是为了并发而来的.
3) 考虑下面的代码, some_obj
是一个多个线程可见的共享变量.
1 2 3 4 5 |
|
如果线程 1 与线程 2 能够并行, 那么当 some_ojb
是如下类型时, 代码是否能够正确同步?
- e)
mutex
- f)
condition_variable
- g)
atomic<unsigned>
对上面的三个类型来说, 代码是 OK 的, 因为他们本身就是内部同步的, 所有不需要在外部再做同步.
实际上, 这些类型必须保证不需要外部同步时也是安全的, 因为这些同步原语, 是你用来保证其他变量同步的工具.
准则: 只有当一个类型的目的是线程间通讯 (如: 消息队列) 或同步(如: mutex)时, 这个类型才需要是内部同步的
4) 外部同步, 意味着使用共享对象的代码需要自己来保证对象的同步. 回答下面有关外部同步的问题:
a) 一般的外部同步的职责是什么?
同步的职责很简单: 当有可写的共享变量时, 需要同步访问它. 典型的做法是使用 mutex
或类似的东西, 或者如果可行的话将该类型变为 atomic
.
准则: 如果代码中访问了可写的共享变量, 那么需要对他进行访问同步.
b) 什么是”基本的线程安全保障”?
如果要保证上面描述的东西是正确的, 那么对象本身必须有如下两个保证.
首先, 并行访问两个实例对象必须是安全的. 比如, 类 X
有两个对象 x1
和 x2
, 每个对象都仅在自己的线程中使用. 考虑下面的情形:
1 2 3 4 5 6 7 |
|
这必须始终是正确同步的. 记住, 这里的 x1
和 x2
是两个对象, 而不是别名或类似的东西.
另外, 并发的 const 操作, 也就是只读操作必须是安全的:
1 2 3 4 5 6 7 |
|
上面的代码也是正确同步的. 没有外部同步时他们也能很好的工作. 这不是一个竞态条件. 因为这两个线程都仅仅是对共享变量进行读操作.
这把我们带到了同时需要外部同步和内部同步的情形.
c) 哪些内部同步是在共享变量的实现中需要做的?
在某些类, 对象中表面上看起来他们是不同的, 但实际上仍然共享着某些状态, 而不需要调用者做任何事情来指定幕后的连接状态. 注意这不是前面准则的例外, 这是和前面一样的准则.
准则: 如果代码中访问了可写的共享变量, 那么需要对他进行访问同步. 这始终是正确的. 如果可写的共享变量隐藏在类实现的内部, 那么仅仅对那部分共享变量的访问做同步即可.
引用计数, 就是上面所描述的内部共享状态, 下面的两个例子是 std::shared_ptr
以及 copy-on-write. 下面来看 shared_ptr
的例子.
像 shared_ptr
一样的带有引用计数的智能指针会在对象内部保存引用计数信息. 下面来看两个不同的 shared_ptr
对象 sp1
与 sp2
, 每一个都仅在自己的线程中使用. 考虑下面的情形:
1 2 3 4 5 6 7 |
|
这个代码会正确同步, 而且完全不需要外部同步. 没错…
但是, 如果 sp1
与 sp2
指向同一个对象, 共享引用计数信息时呢? 这时, 引用计数信息是一个可写的共享变量, 它必须同步访问来避免竞态条件, 但是这基本是无法在外部调用代码中完成的, 因为我们甚至都感知不到它存在共享的内容. 我们看不到引用计数的大小, 也不知道他的变量名, 也不知道还有谁正在共享.
类似的, 我们看下面代码, 两个线程只从同一个变量 sp
中读取内容:
1 2 3 4 5 6 7 |
|
代码不需要外部同步也可以正确同步. 这不是竞态条件, 因为这两个线程对 sp
都进行的是只读的 const 操作. 但在内部共享的引用计数是可写的, 它们需要正确同步来避免竞态条件, 像上面所说, 我们不可能在调用代码中保证它正确同步, 因为我们甚至不知道有共享的内容.
因此要处理这种情形, 以 shared_ptr
的引用计数为例, 典型的做法是将引用计数变量更改为 mutable atomic 类型.
为了完整, 我们再看需要外部同步的情形. 像上面说说的, 当多线程共享 shared_ptr
可写对象时, 仍然是需要外部同步的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
所以, shared_ptr
不是内部同步的类型, 如果调用者需要在多个线程中共享该类型的可写变量, 那么必须像问题 3(d) 中那样, 在外部做同步访问.
那么内部同步的目的是什么? 它仅仅做那些外部不可见, 外部无法做同步, 而内部需要共享的内容的同步. 这样一来外部就能使用通常的做法来保证正确同步了.
对于 copy-on-write 来说, 情况和引用计数也类似.
准则: 如果你设计一个类, 如果两个对象实例之间有外部看不到的 mutable
共享状态, 那么保证这个共享状态能够正确同步是你的职责, 因为这个共享状态对外部来说是未知的.
为什么内部共享状态是 mutable
的, 请看 GotW #6a 和 #6b.
5) 完全的内部同步类型(线程安全类型), 意味着所有的同步在对象内部完成, 外部不需要再进行同步. 哪些类型是内部同步的, 为什么?
只有一种类型需要完全的内部同步, 不需要外部的同步就能保证并发的安全, 那就是: 线程间同步和通讯的原语. 这包括标准库中的 mutex
, atomic
, 还有你可能自己会写的线程间通信的消息队列, 生产者/消费者的活动对象, 或者一个线程安全的计数器.
如果你想知道是否还有其他的类型也应该做成内部同步的话, 请考虑: 只有那些你能明确知道,这个类型一旦创建, 那它就是要共享给多个线程来做可写访问的时候, 你猜需要让这个类型是内部同步的… 这个语义同时也意味着这个类型就是为了线程间通讯以及同步而设计的.