前言
在C++并发编程中,保护共享数据最简单的方法是使用互斥量。
在访问共享数据前,开发者可使用互斥量将相关数据锁住,并于访问结束后将数据解锁。因此,线程库需要保证当一个线程使用特定互斥量锁住共享数据时,其他线程仅可在数据被解锁后才能访问。
互斥量是C++中最通用的一种数据保护机制,但它并非“银弹”,在使用互斥量时,需要精心组织代码来保护正确的数据,并在接口内部避免竞争条件。此外,互斥量自身存在一定的问题,它可能会导致死锁,又或者将某些数据保护地太多或者太少。下文将针对上述情况逐一作出分析。
在C++中使用互斥量
C++中通过实例化mutex
创建互斥量,并由其成员函数lock
与unlock
完成上锁与解锁操作。但在实际使用环境下并不推荐直接使用mutex
成员函数,原因在于一旦调用lock
,开发者必须保证在所有函数函数出口(包括异常)中调用unlock
。
C++标准库为互斥量提供了一个具备RAII的模板类std::lock_guard
,该类对象总会在构造时提供已锁的互斥量,并在对象析构后完成解锁。mutex
与std::lock_guard
均被声明于头文件mutex
头文件内。
使用实例
下述实例将表明如何在C++中使用互斥量对一个list
内的数据提供访问保护。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::list<int> some_list;
std::mutex some_mutex;
void add_to_list(int new_value) {
std::lock_guard<std::mutex> guard(some_mutex);
some_list.push_back(new_value);
}
bool list_contains(int value_to_find) {
std::lock_guard<std::mutex> guard(some_mutex);
return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();
}
在上述使用过程中,some_list
是一个全局变量,它被一个全局互斥量some_mutex
所保护。虽然某些情况下使用全局变量没问题,但在大多数应用场景中,互斥量通常会与保护的数据位于同一个类内,而非像上文一样作为全局变量。显然,这是面向对象设计准则:将互斥量作为data member
置于类中。类似地,函数add_to_list
和list_contains
应当作为成员函数。需要注意的是,互斥量与要保护的数据在类中需要被定义为private成员,所有成员函数均需在调用时对数据上锁,结束时对数据解锁,如此则可保证数据不被破坏(这里说的并不完全正确,如果成员函数均内置有上锁操作,则内部调用时需要嵌套锁机制)。
当然,现实情况并非总是如此理想,应该意识到,如果一个成员函数返回的是保护数据的指针或引用,那么就一定存在数据破坏的可能性,原因在于使用者可以通过引用或指针直接访问数据,从而绕开互斥量的保护。因此,如果一个类使用互斥量来保护自身数据成员,其开发者必须谨小慎微地设计接口,确保互斥量能锁住任何对数据的访问,并且不留后门。
精心组织代码以保护共享数据
正如上文所述,使用互斥量来保护数据时并非只是在每一个成员函数中都加入一个std::lock_guard
对象那么简单,任何一个传递至外界的指针或引用都将令这种保护形同虚设。某些开发者认为规避这项风险非常容易:仅仅需要在设计过程中检查接口是否返回指向内部数据的handle即可,但事实绝非如此。在确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也非常重要(尤其是该操作不在你的控制下时)。成员函数可能在没有互斥量保护的区域内存储着指针或引用,这是一种非常危险的行为,更危险地是:将保护数据作为一个运行时参数。下述实例展示了这一点。
问题实例
1 | class some_data { |
实例分析
看起来process_data
没有任何问题,但调用用户自定义的func
则意味着foo
可以绕过保护机制将函数malicious_function
传入,在没有互斥量锁定的情况下调用do_something
。
从review结果来看,开发者只是单纯地将所有可访问的数据结构代码标记为互斥而已,但在foo()
中调用 unprotected->do_something()
的代码未能被标记为互斥。C++标准库并不能针对这种行为做出保护,因此必须谨记:切勿将受保护数据的指针或引用传递到互斥锁作用域之外。
发现接口内在的条件竞争
问题背景
假设当前存在一个stack class
,除去构造函数与swap外,其大致有5个接口:
- push()
- pop()
- top()
- empty()
- size()
1 | template<typename T,typename Container=std::deque<T> > |
出于性能的要求,我们一般会将top()
返回一个引用而非拷贝,显然这与上文所要求的不符。但需要注意的是即使修改了top()
,使其返回一个拷贝,该接口依然存在条件竞争。更进一步地说,该问题与使用互斥量并无关联,在无锁编程实现的接口中条件竞争也依然存在。总之,这是接口本身所具备的性质,与实现方式无关。
条件竞争实例
虽然empty()
与size()
在被调用并返回时是正确的,但其结果并不可靠:当它们返回后,其他线程则可自由地访问栈,并且push()多个新元素至栈中;当然,也可能pop()一些已在栈中的元素。在这种情况下,empty()
与size()
返回值可认为是无效的。
更进一步地,当工作环境为单线程时,使用empty()
判空后再调用top()
是一种安全行为:1
2
3
4
5
6stack<int> s;
if (!s.empty()) {
int const value = s.top();
s.pop();
do_something(value);
}
但在并发编程中上述代码将不再安全,因为在调用empty()
和调用top()
之间,可能有来自另一个线程的pop()
调用,并且该调用可能删除了最后一个元素。这是一个经典的条件竞争问题,使用互斥量对栈内部数据进行保护并不能阻止条件竞争的发生,即接口固有问题。
问题分析
由于问题来源于接口设计,因此解决问题势必需要更改接口。有人提出这样一个方案:在调用top()
时检查当前容器是否为空,若是则抛出异常。但该解决方案非常拙劣,也就是说,即使empty()
返回了false,我们仍然需要异常捕获机制,本质上这令empty()
成为了一个多余接口。
另一个潜在的条件竞争发生于top()
与pop()
之间。假设存在两个线程运行着上述程序,并且他们分享着同样的栈实例,若初始条件下栈内仅存在两个元素,由于stack对象含有互斥量,因此成员函数只能够交错运行,但do_something
作为非成员函数可以并发运行。针对上述假设,可能存在某种执行顺序如下表所示。
Thread A | Thread B |
---|---|
if (!s.empty()) | |
if (!s.empty()) | |
int const value = s.top(); | |
int const value = s.top(); | |
s.pop() | |
do_something(value); | s.pop() |
do_something(value); |
我们的本意是线程A与线程B分别调用了top()
与pop()
,从而获取到了栈内的第一个与第二个元素,但在这种执行顺序下,线程A与线程B获取的均为同一个值,而另一个值被直接丢弃了。相对于未定义行为,这种条件竞争更加难以定位和排查。
可能有读者因为长期使用C++,因此在阅读上文的过程中一头雾水,并未了解这种接口设计有何不合理之处。一言以蔽之:在另一些语言中,pop()
接口将直接移除顶端对象并返回其拷贝,这种接口设计将保证上述条件竞争不再发生(由于操作均集中在成员函数内部没有被分割,而成员函数本身是交错运行的)。具体来说,为了获取被移除的顶部元素,C++开发者必须写:1
2vector<int> temp = s.top();
s.pop();
而不能够像java开发者一样:1
ArrayList<int> temp = s.pop();
问题起因
部分读者可能会好奇为何C++标准库会存在这种设定,现有说明如下。
假设当前存在一个 stack<vector<int>>
。众所周知,vector是一个动态容器,其拷贝操作会令标准库在堆分配较多的内存(其大小等同于vector内所有元素的大小之和)。若当前系统处于重度负荷状态,或存在严重的资源限制,该内存分配行为将会失败,vector的拷贝构造函数将会抛出一个 std::bad_alloc
异常。
试请读者回想一下,如果stack的pop()
存在返回值,将导致一个潜在问题:由于仅在栈对象发生改变后(移除了顶层元素)拷贝值才会被传递至调用函数,但如果拷贝操作抛出了一个异常怎么办?显然,需要被弹出的数据将会丢失,但栈内元素却已经被移除。std::stack
的设计人员为了规避上述可能,将其接口设置为void pop()
。这种接口设计固然杜绝了上述问题,但却在并发编程环境下引入了条件竞争。
解决方案
传入引用
一种实现思路是将元素的引用作为参数传入pop()
以获取对应数据:1
2
3
4
5
6
7
8
9
10template<typename T,typename Container=std::deque<T> >
class stack {
public:
...
// member function declaration
void pop(T&);
};
std::vector<int> result;
some_stack.pop(result);
该方案可以用于多数应用场景,但缺点也十分明显:需要构造出一个栈中类型的实例,用于接收目标值(即上述实例中的result)。构造实例主要存在三种问题:
- 对于某些类型而言,这种做法不切实际,因为临时构造一个实例从时间和资源的角度来看都不划算。
- 部分类型不支持默认构造函数,而构造它所需要的参数此时是未知或者不确定的。
- 这种操作需要类型具备可赋值属性(
operator=
),而很多用户自定义类型可能并不支持赋值操作。
无异常抛出的拷贝构造函数或移动构造函数
在设计之初之所以将pop()
返回值设定为void,是因为返回一个具体的值存在异常安全问题。那么追本溯源之后,读者自然会想到设计某种不会抛出异常的拷贝或移动构造函数。
这种做法虽然安全,但决不能称之为可靠。尽管C++标准库能够令开发者在编译时使用std::is_nothrow_copy_constructible
和std::is_nothrow_move_constructible
类型特征来判断拷贝/移动构造函数是否抛出异常,但这种方式的局限性太强。大量的用户自定义类型的拷贝/移动构造函数不具备nothrow属性,将它们隔离于线程安全的stack之外似乎不太友好。
返回指向弹出值的指针
第三种方案是返回一个指向弹出元素的指针,而不是直接返回值。指针的优势在于它可以自由拷贝,并且不会产生异常。但缺点在于指针也需要占据一定的内存空间,对于简单数据类型(比如int),这种开销要远大于直接返回值。
在该解决方案下,推荐使用std::shared_ptr
作为函数返回值。智能指针不仅能避免内存泄露,并且其内存分配全部由标准库完成,即不需要手动地new与delete(使用make_shared
)。
“方案1 + 方案2”或 “方案1 + 方案3”
通用的代码不应当忽视灵活性,当你已经选择了方案2或3后,在此基础上增加方案1也并不困难。我们可以在确保安全的提前下提供足够多的选择给用户,让他们自己选择当前使用环境下最合适,最经济的方案。
解决实例
下述实例组合了方案1与方案3,并在其中封装了std::stack
,其代码概述如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct empty_stack: std::exception {
const char* what() const throw() {
return "empty stack!";
};
};
template<typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() : data(std::stack<T>()){}
threadsafe_stack(const threadsafe_stack& other) {
std::lock_guard<std::mutex> lock(other.m);
data = other.data; // 调用stack::operator=
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value) {
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value) {
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
value=data.top();
data.pop();
}
bool empty() const {
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
死锁
首先以一个最基本的例子来抽象什么是死锁:1
2
3面试ing....
面试官:"如果你能够讲清楚什么是死锁,我就给你发offer。"
候选人:"如果你能够给我发offer,我就告诉你什么是死锁。"
死锁是这样一种场景:存在一对线程,他们都需要执行一些操作,这些操作以锁住自己的互斥量作为开头,并且需要对方释放其持有的互斥量,在这种场景下没有线程能够正常工作,因为它们都在等待对方释放互斥量。当存在两个以上的互斥量锁定同一个操作时,死锁很容易发生。
避免死锁
一般来说,让两个互斥量总是以同样的顺序上锁即可避免死锁,如总是在锁住互斥量A之前锁住互斥量B。但是在某些应用场景下事情没这么简单,比如说多个互斥量保护同一个类的独立实例时。考虑如下场景:某类存在一个对象间数据交换操作(类似于swap
),为了确保正确地交换数据而不受并发环境影响,我们需要锁定两个实例的互斥锁。由于swap
需要接受两个参数(两个实例),假设第一个参数先加锁,第二个后加锁,当swap()
两个操作对象实际为同一实例时将导致死锁。
幸运地是,C++标准库提供了同时锁住多个互斥量且没有死锁风险的操作:std::lock
。下文将展示如何将std::lock
应用于swap
函数中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd):some_detail(sd){}
friend void swap(X& lhs, X& rhs) {
if(&lhs==&rhs) return;
std::lock(lhs.m,rhs.m);
std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock);
std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);
swap(lhs.some_detail,rhs.some_detail);
}
};
首先执行的是判同操作,原因在于接下来的代码块试图加锁lhs与rhs内的mutex,如果lhs与rhs等价,那么反复加锁将导致未定义行为。(C++允许在一个线程内反复对mutex执行加锁操作,std::recursive_mutex
提供了这样的功能)。然后调用std::lock
锁住两个互斥量,并创建2个std::lock_guard
对象用于管理锁。值得注意的是std::lock_guard
对象在创建过程中使用了额外参数std::adopt_lock
,该参数提醒std::lock_guard
对象其所管理的mutex
对象已经上锁,它需要做的是管理锁的拥有权而非是在构造函数中再次执行lock
操作。
需要注意的是,当使用std::lock
去加锁lhs.m
或rhs.m
时可能会抛出异常,并且该异常会传播到std::lock
之外。当std::lock
获取第一个mutex
上的锁,并在试图获取第二个mutex
上的锁失败抛出异常后,第一个锁也会随之解除。总而言之,std::lock
要么都锁上,要么都不锁,即坚持所谓的“all or nothing”语义。
尽管std::lock
可以保证在需要锁住多个互斥量的情况下不会出现死锁,但std::lock
并不支持获取其中某一个锁。避免死锁是一种非常依赖开发者经验的高难度动作,不过话虽如此,也存在一些简单的规则能帮助你写出“无死锁”的代码。
进一步地避免死锁
死锁这个名字总让人觉得仅有上锁操作才能触发死锁,但实际情况并非如此。举例而言,在无锁编程环境下,我们仅仅需要两个线程就能触发死锁:在它们的执行函数中调用对另一个线程的join
即可。在这种情况下,两个线程均不能正常工作,因为他们都在等待对方结束(而这是不可能的)。此外,死锁并不一定仅仅发生于两个线程之间,而是多个线程。
以下将简单地给出一些如何鉴别与消除死锁的指导意见。
避免一个线程持有多个锁
尽可能令一个线程仅仅持有一个锁,如此则可避免由于锁的不正当使用造成的死锁问题。当然了,正如前文所说,死锁并不仅仅产生于锁的使用错误,线程相互等待也会造成死锁,不过总的来看,大部分死锁都源于互斥锁。如果你坚持在一个线程中持有多个锁,记得使用std::lock
。
避免在持有锁时调用用户自定义程序
用户自定义程序对于开发者而言是未知的,我们并不确定它们可能执行何种操作。它们有可能会去获取一个锁,如果当前线程已经持有了一个锁,那么再调用用户自定义程序将违反原则1:避免持有多个锁。但有时调用用户自定义程序在所难免,比如我们正在撰写一个类似于前文stack的泛型库,所有操作几乎均依赖于用户传入的数据类型(它们可能是用户自定义的),在此情况下,我们需要参照新的指导原则。
以固有顺序获取锁
如果硬性条件约束我们不得不获取多个锁并且不可使用std::lock
,那么指导意见是在每一个线程中均保证以同样的顺序获取这些锁。某些情况下这很容易做到,但在另外一些环境中却很难实现。总之,我们可以建立某种约定,一个线程必须在锁住A后才能获取B的锁,锁住B后才能获取C的锁,即以禁止反向遍历为代价消除死锁产生的可能性,类似的约束通常也被用于建立数据结构。
使用层次锁
层次锁是上一个指导意见的特例,用于检查运行期约定是否被遵守。该指导意见要求开发者将应用程序层次化,并能够识别给定层上所有可被上锁的互斥量。当代码试图执行上锁操作时,它会检查当前是否已持有来自低层次的锁,若有则禁止上锁当前互斥量。你可以在运行期检查这一约束条件,通过对每一个互斥量分配层级编号并记录所有被线程上锁的互斥量的方式。遗憾的是C++标准库并没有提供相关机制,因此我们不得不自己实现一个hierarchical_mutex
,先不考虑其具体定义,它的使用机制应该与下文类似:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(5000);
int do_low_level_stuff();
int low_level_func() {
std::lock_guard<hierarchical_mutex> lk(low_level_mutex);
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func() {
std::lock_guard<hierarchical_mutex> lk(high_level_mutex);
high_level_stuff(low_level_func());
}
void thread_a() {
high_level_func();
}
hierarchical_mutex other_mutex(100);
void do_other_stuff();
void other_stuff() {
high_level_func();
do_other_stuff();
}
void thread_b() {
std::lock_guard<hierarchical_mutex> lk(other_mutex);
other_stuff();
}
我们简单地分析一下thread_a
与thread_b
的运行情况。简单地来说,thread_a
遵守了层级规则,而thread_b
没有。我们可以注意到,thread_a
调用了high_level_func
,因此高层级互斥量high_level_mutex
被上锁,随后又试图去调用low_level_func
,此时低层级互斥量low_level_mutex
被上锁,这与上文提及的规则一致:先锁高层级再锁低层级。thread_b
的运行则没有这么乐观,它先锁住了层级为100的other_mutex
,并在之后试图去锁住高层级的high_level_mutex
,此时会发生错误,可能会抛出一个异常,又或者直接终止程序。
显然,使用层次锁时不可能由于错误的上锁顺序而产生死锁,因为互斥量必然以一定顺序逐一上锁。这也意味着你不可能在同一层级上同时持有多个锁,层次锁要求所有互斥量都处于一条链上,并且每个互斥量的层级值都必须低于它的前一个。
hierarchical_mutex
可以作为模板参数传入std::lock_guard
,因此它必须实现三个接口:lock
、unlock
、try_lock
,其大致实现如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43class hierarchical_mutex {
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value;
void check_for_hierarchy_violation() {
if(this_thread_hierarchy_value <= hierarchy_value) {
throw std::logic_error(“mutex hierarchy violated”);
}
}
void update_hierarchy_value() {
previous_hierarchy_value=this_thread_hierarchy_value;
this_thread_hierarchy_value=hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value):
hierarchy_value(value), previous_hierarchy_value(0) {}
void lock() {
check_for_hierarchy_violation();
internal_mutex.lock();
update_hierarchy_value();
}
void unlock() {
this_thread_hierarchy_value=previous_hierarchy_value;
internal_mutex.unlock();
}
bool try_lock() {
check_for_hierarchy_violation();
if(!internal_mutex.try_lock())
return false;
update_hierarchy_value();
return true;
}
};
thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
需要注意的是,hierarchical_mutex
使用了一个static data memberthis_thread_hierarchy_value
来表征当前线程层级值,它被初始化为最大值,因此在初始阶段任何互斥量均可被锁住。由于其具备thread local
属性,因此每个线程都有其拷贝副本,各线程中变量状态完全独立。每次完成lock
或unlock
都将更新当前线程层级值。
更加灵活的std::unique_lock
相较于std::lock_guard
,std::unqiue_lock
并不与互斥量的数据类型直接相关,因此使用起来更加灵活。它在构造时可以传入额外的参数,如std::adopt_lock
与std::defer_lock
,前者用于管理互斥量,后者则用于表明当前互斥量应当保持解锁状态。如此一来,可以通过std::unique_lock
的成员函数lock()
执行上锁操作,又或者将std::unique_lock
对象传入std::lock
完成上锁。
std::unqiue_lock
的时空间性能均劣于std::lock_guard
,这也是它为灵活性付出的代价:std::unqiue_lock
内存在某种标志用于表征其实例是否拥有特定的互斥量,显然,这些标志需要占据空间,并且标志的检查与更新也需要耗费时间。
应用实例
以下将简单地描述如何用std::unique_lock
替换std::lock_guard
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd):some_detail(sd){}
friend void swap(X& lhs, X& rhs) {
if(&lhs==&rhs) return;
std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock);
std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock);
std::lock(lock_a,lock_b);
swap(lhs.some_detail,rhs.some_detail);
}
};
互斥量所有权的传递
由于std::unique_lock
并没有与自身相关的互斥量,因此互斥量所有权可以在不同实例间相互传递,std::unique_lock
是一个标准的`move only object”。
互斥量所有权传递十分常见,比如在某个函数内完成对互斥量的上锁,并在其后将其所有权转交至调用者以保证它可以在该锁的保护范围内执行额外操作:1
2
3
4
5
6
7
8
9
10
11std::unique_lock<std::mutex> get_lock() {
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk;
}
void process_data() {
std::unique_lock<std::mutex> lk(get_lock());
do_something();
}
std::unique_lock
的成员函数lock
支持在其实例销毁之前放弃其拥有的锁。当锁没有必要长期持有时就应当主动释放,这对提升应用程序的性能十分有利。
锁的粒度
锁的粒度是一个摆手术语(hand-waving term),用来描述一个锁保护着的数据量大小。一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。简而言之,我们应当针对待保护数据量的大小选择合适的粒度,太大则过犹不及,太小则不足以保护。
假设当前存在这样一个场景:很多线程正在等待同一个资源,若某个线程持有锁的时间过长(不必要地占用着资源),这将导致程序整体时间性能下降。在理想情况下,我们仅仅会在访问某个共享数据时才会上锁,任何非共享数据的处理操作都应当在锁外执行。我们万不可在持有锁时执行一些特别费时的操作,比如文件的输入/输出。文件的IO操作往往要比内存读写慢上百倍,因此,除非当前存在用锁去保护文件访问的必要,我们绝不应当在持有锁时执行文件IO,这将造成其他线程不必要地阻塞,最终导致多线程带来的性能优势被这种拙劣的操作抵消殆尽。
这种情况下std::unique_lock
将运行地非常完美,当需要处理锁外数据时,它将调用成员函数unlock
以解除锁定,并在需要访问共享数据时调用lock
再次上锁:1
2
3
4
5
6
7
8void get_and_process_data() {
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process=get_next_data_chunk();
my_lock.unlock(); // 处理前解锁
result_type result=process(data_to_process);
my_lock.lock(); // 写入前加锁
write_result(data_to_process,result);
}
正如上述实例所示,粒度并不仅仅表示锁保护的数据量,也表示控制锁的时间。通常情况下,持有锁的时间应当等价于执行操作所需的最短时间。因此除非必须执行,我们不应该在持有锁时执行耗时操作(比如获取另一个锁(即使你明确了解该操作不会造成死锁)或等待IO操作完成)。
在前文中我们曾经叙述过在并发编程下完成两个数据结构的交换操作,现在让我们试想下面这个场景:存在一个简单的数据结构Y,其内部数据仅仅含有一个int,现在需要完成两个Y实例之间的判同操作。由于复制一个int并不耗时,因此我们可以在持有锁的情况下完成拷贝操作,并返回两个被拷贝的int等价与否,这意味着我们不再需要同时持有两个锁,并且按照要求实现了仅仅在需要的时间段上锁:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Y {
private:
int some_detail;
mutable std::mutex m;
int get_detail() const {
std::lock_guard<std::mutex> lock_a(m);
return some_detail;
}
public:
Y(int sd):some_detail(sd){}
friend bool operator==(Y const& lhs, Y const& rhs) {
if(&lhs==&rhs) return true;
int const lhs_value=lhs.get_detail();// 1
int const rhs_value=rhs.get_detail();// 2
return lhs_value==rhs_value;
}
};
需要注意的是,如果该结果返回了true,我们仅仅只能够保证在时间点1上的lhs.some_detail与时间点2上的rhs.some_detail相同,这两个值在被读取后可能会被任意的方式所修改。因为我们持有锁的时间并没有达到整个操作所需要的时间,因此该判同语义未必符合预期。
在某些情况下并不存在一个合适的粒度大小,因为并非所有对数据结构的访问都需要相同的保护级别。因此,你可能需要另一种机制来替换std::mutex
。