C++并发编程实战读书笔记——线程间共享数据

When it comes down to it, the problems with sharing data between threads are all due
to the consequences of modifying data. If all shared data is read-only, there’s no problem, because the data read by one thread is unaffected by whether or not another thread is reading the same data.
当一个或多个线程要修改共享数据时,就会产生很多麻烦。

条件竞争
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,其结果可以接受。当不变量遭到破坏时,才会产生条件竞争。并发中对数据的条件竞争通常表示为恶性条件竞争。恶性条件竞争通常发生于对多于一个的数据块的修改时。因为操作要访问两个独立的数据块,独立的指令将会对数据块进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。
解决恶性条件竞争的方法:

  1. 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度看,修改不是已经完成,就是还没开始。
  2. 对数据结构和不变量的设计进行修改,修改完的结构必须完成一系列不可分割的变化,保证每个不变量保持稳定的状态。
  3. 适用事务的方式去处理数据结构的更新。所需的一些数据和读取操作都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,提交不能进行处理,则事务将重启。这被称为软件事务内存software transactional memory (STM)。

使用互斥量保护共享数据(mutexes)

当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。
C++中通过实例化 std::mutex 创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。不过,不推荐实践中直接去调用成员函数,因为调用成员函数就意味着,必须记住在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII
语法的模板类 std::lock_guard其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。

#include <list>
#include <mutex>
#include <algorithm>
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();
}

add_to_list()和list_contains()函数中使用std::lock_guardstd::mutex,使得这两个函数中对数据的访问是互斥的:list_contains()不可能看到正在被add_to_list()修改的列表。
虽然某些情况下,使用全局变量没问题,但在大多数情况下,互斥量通常会与保护的数据放在同一个类中,而不是定义成全局变量。这是面向对象设计的准则:将其放在一个类中,就可让他们联系在一起,也可对类的功能进行封装,并进行数据保护。在这种情况下,函数add_to_list和list_contains可以作为这个类的成员函数。当其中一个成员函数返回的是保护数据的指针或引用时,会破坏对数据的保护。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,并且不留后门。

死锁

线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都再等待对方释放互斥量。
threads arguing over locks on mutexes: each of a pair of threads needs to lock both of a pair of mutexes to perform some operation, and each thread has one mutex and is waiting for the other. Neither thread can proceed, because each is waiting for the other to release its mutex. This scenario is called deadlock, and it’s the biggest problem with having to lock two or more mutexes in order to perform an operation.

避免死锁的一般建议就是让两个互斥量总以相同的顺序上锁:总是在互斥量B之前锁住互斥量A。The common advice for avoiding deadlock is to always lock the two mutexes in the same order。事情没这么简单,比如:当有多个互斥量保护同一个类的独立实例时,一个操作对同一个类的两个不同实例进行数据的交换操作,为了保证数据交换操作的正确性,就要避免数据被并发修改,并确保每个实例上的互斥量都能锁住自己要保护的区域。不过,选择一个固定的顺序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!

C++标准库有办法解决这个问题,std::lock可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)。

class 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);
	}
};

需要先检查参数是否是不同的实例,因为操作试图获取std::mutex对象上的锁,所以当其被获取时,结果很难预料。(一个互斥量可以在同一线程上多次上锁)调用std::lock()锁住两个互斥量,并且两个std::lock_guard实例已经创建好。提供std::adopt_lock参数向std::lock_guard表明mutexes已经上锁,并接受mutex已经上锁而不是尝试在构造函数中给mutex上锁。(The std::adopt_lock parameter is supplied in addition to the mutex to indicate to the std::lock_guard objects that the mutexes are already locked, and they should just adopt the ownership of the existing lock on the mutex rather than attempt to lock the mutex in the constructor.)需要注意的是,当使用std::lock去锁lhs.m或rhs.m时,可能抛出异常,在这种情况下,异常会传播到std::lock之外。当std::lock成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以std::lock要么将两个锁都锁住,要不一个都不锁。

Further guidelines for avoiding deadlock

  • 避免嵌套锁
  • 避免在持有锁时调用用户提供的代码
  • 使用固定顺序获取锁
  • 使用锁的层次结构

std::unique_lock 灵活的锁

std::unique_lock以互斥量为参数的模板类,并且能够管理RAII类型锁就像std::lock_quard一样。a std::unique_lock instance doesn’t always own the mutex that it’s associated with. 可以将 std::defer_lock 作为第二个参数传递进去,表明互斥量在构造时应保持解锁状态。这样,lock()函数通过std::unique_lock 对象,或通过传递std::unique_lock 对象到 std::lock() 中来获取锁。std::unique_lock 会占用比较多的空间,并且比 std::lock_guard 稍慢一些。保证std::unique_lock 灵活性要付出代价,这个代价就是允许 std::unique_lock 实例不带互斥量:信息已被存储,且已被更新。

class 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 支持lock(), try_lock()和unlock()成员函数,所以能将std::unique_lock对象传递到 std::lock() 。这些同名的成员函数在低层做着实际的工作,并且仅更新 std::unique_lock 实例中的标志,来确定该实例是否拥有特定的互斥量,这个标志是为了确保unlock()在析构函数中被正确调用。如果实例拥有互斥量,那么析构函数必须调用unlock();但当实例中没有互斥量时,析构函数就不能去调用unlock()。这个标志可以通过owns_lock()成员变量进行查询。

不同域中互斥量所有权的传递

std::unique_lock 实例不必拥有与自身相关的互斥量,一个互斥量的所有权可以通过移动操作,在不同的实例中进行传递。某些情况下,这种转移是自动发生的,例如:当函数返回一个实例;另些情况下,需要显式的调用 std::move() 来执行移动操作。从本质上来说,这取决于源值是否是左值——一个实际的值或是引用或一个右值——一个临时类型。当源值是一个右值(所有权转移是自动的)。为了避免转移所有权过程出错,左值就必须显式移动成。 std::unique_lock 是可移动,但不可赋值的类型。

std::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();
}

因为lk是在函数中定义的自动变量,可以直接返回而不用调用std::move()。(编译器调用移动构造函数)process_data()函数可以将所有权直接转移给std::unique_lock实例。std::unique_lock 的灵活性同样也允许实例在销毁之前放弃其拥有的锁。可以使用unlock()来做这件事,如同一个互斥量: std::unique_lock 的成员函数提供类似于锁定和解锁互斥量的功能。 std::unique_lock 实例在销毁前释放锁的能力,当锁没有必要在持有的时候,可以在特定的代码分支对其进行选择性的释放。

锁的粒度

锁的粒度是一个摆手术语(hand-waving term),用来描述通过一个锁保护着的数据量大小。一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。如果很多线程正在等待同一个资源,当某个线程持有锁的时间过长,着会增加等待时间。当真正要访问共享数据时才去上锁,且在锁外做耗时的数据处理工作。
std::unique_lock 在这种情况下工作正常,在调用unlock()时,代码不需要再访问共享数据;而后当再次需要对共享数据进行访问时,就可以再调用lock()了。

void 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);
}

锁的合适粒度不仅是所锁数据的多少还是需要锁定的时间和锁定期间的操作。In general, a lock should be held for only the minimum possible time needed to perform the required operations.

class 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();
		int const rhs_value=rhs.get_detail();
		return lhs_value==rhs_value;
	}
};

比较操作符首先调用get_detail()成员函数检索要比较的值。函数在索引值时被一个锁保护。比较操作符会在之后比较索引出来的值。虽然这样减少了锁持有的时间。但是在操作符返回true时,就意味着在这个时间点上ihs.son_detail与在另一个时间点的rhs.some_detail相同。这两个值在读取之后,可能被任意方式所修改,这样就会失去比较的意义。

保护共享数据的替代设施

保护共享数据的初始化过程

延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定,数据是否需要初始。

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
	if(!resource_ptr)
	{
		resource_ptr.reset(new some_resource);
	}
	resource_ptr->do_something();
}

如果共享数据对于并发访问是安全的,那么在多线程代码中需要保护的就是共享数据的初始化。

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
	std::unique_lock<std::mutex> lk(resource_mutex);
	if(!resource_ptr)
	{
		resource_ptr.reset(new some_resource);
	}
	lk.unlock();
	resource_ptr->do_something();
}

上面的转换使得线程资源产生不必要的序列化,因为每个线程为了确定数据源已经初始化必须等待互斥量。

void undefined_behaviour_with_double_checked_locking()
{
	if(!resource_ptr)
	{
		std::lock_guard<std::mutex> lk(resource_mutex);
		if(!resource_ptr)
		{
			resource_ptr.reset(new some_resource);
		}
	}
	resource_ptr->do_something();
}

声名狼藉的双重检查锁模式:指针第一次读取数据不需要获取锁只有在指针为NULL时才需要获取锁。然后,当获取锁之后,指针会被再次检查一遍。这里有潜在的条件竞争,未被锁保护的读取操作没有与其他线程里被锁保护的写入操作进行同步。这个条件竞争不仅覆盖指针本身,还会影响到其指向的对象;即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调do_something()后,得到不正确的结果。

C++标准库提供了 std::once_flag 和 std::call_once 来处理这种情况。值得注意的是,std::mux和std::one_flag的实例不能拷贝和转移。

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
	resource_ptr.reset(new some_resource);
}
void foo()
{
	std::call_once(resource_flag,init_resource);
	resource_ptr->do_something();
}

every thread can just use std::call_once, safe in the knowledge that the pointer will have been initialized by some thread (in a properly synchronized fashion) by the time
std::call_once returns.每个线程只需要使用 std::call_once ,在 std::call_once 的结束时,就能安全的知道指针已经被其他的线程初始化了。在这个例子中, std::once_flag 和初始化好的数据都是命名空间区域的对象,但是 std::call_once() 可仅作为延迟初始化的类型成员。

class X
{
private:
	connection_info connection_details;
	connection_handle connection;
	std::once_flag connection_init_flag;
	void open_connection()
	{
		connection=connection_manager.open(connection_details);
	}
public:
	X(connection_info const& connection_details_):
		connection_details(connection_details_)
	{}
	void send_data(data_packet const& data)
	{
		std::call_once(connection_init_flag,&X::open_connection,this);
		connection.send_data(data);
	}
	data_packet receive_data()
	{
		std::call_once(connection_init_flag,&X::open_connection,this);
		return connection.receive_data();
	}
};	

例子中第一次调用send_data()或receive_data()的线程完成初始化过程。使用成员函数open_connection()去初始化数据,也需要将this指针传进去。

还有一种情况的初始化过程中存在条件竞争:其中一个局部变量被声明为static类型。这种变量的在声明后就已经完成初始化,而对于多线程调用的函数抢着去定义这个变量。在C++11标准中,这些问题都被解决了:初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理,条件竞争终止于初始化阶段,这样比在之后再去处理好的多。在只需要一个全局实例情况下,这里提供一个 std::call_once 的替代方案

class my_class;
my_class& get_my_class_instance()
{
	static my_class instance;
	return instance;
}

多线程可以安全的调用get_my_class_instance()函数,不用为数据竞争而担心。

保护很少更新的数据结构

读者-作者锁(reader-writer mutex):允许两种不同的使用方式:一个作者线程独占访问和共享访问,让多个读者线程并发访问。对于更新操作,可以使用

std::lock_guard<boost::shared_mutex> 
std::unique_lock<boost::shared_mutex>

来上锁,保证更新线程的独占访问。
可以使用下面的锁获取访问权

boost::shared_lock<boost::shared_mutex>

唯一的限制:当任一线程拥有一个共享锁时,如果有一个线程尝试获取一个独占锁时,它将会阻塞,直到其他线程放弃他们的锁;同样的,当任一线程拥有一个独占锁时,其他线程就无法获得共享锁或独占锁,直到第一个线程放弃其拥有的锁。

#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>
class dns_entry;
class dns_cache
{
	std::map<std::string,dns_entry> entries;
	mutable boost::shared_mutex entry_mutex;
public:
	dns_entry find_entry(std::string const& domain) const
	{
	boost::shared_lock<boost::shared_mutex> lk(entry_mutex);
	std::map<std::string,dns_entry>::const_iterator const it=
	entries.find(domain);
	return (it==entries.end())?dns_entry():it->second;
	}
	void update_or_add_entry(std::string const& domain,dns_entry const& dns_details)
	{
	std::lock_guard<boost::shared_mutex> lk(entry_mutex);
	entries[domain]=dns_details;
	}
};

find_entry()使用 boost::shared_lock<> 来保护共享和只读权限①;这就使得多线
程可以同时调用find_entry(),且不会出错。另一方面,update_or_add_entry()使
用 std::lock_guard<> 实例,当表格需要更新时②,为其提供独占访问权限;update_or_add_entry()函数调用时,独占锁会阻止其他线程对数据结构进行修改,并且阻止线程调用find_entry()。

嵌套锁

当一个线程已经获取一个 std::mutex 时(已经上锁),并对其再次上锁,这个操作就是错误
的,并且继续尝试这样做的话,就会产生未定义行为。然而,在某些情况下,一个线程尝试
获取同一个互斥量多次,而没有对其进行一次释放是可以的。之所以可以,是因为 C++ 标准库提供了 std::recursive_mutex 类。当你调用lock()三次时,你也必须调用unlock()三次。

猜你喜欢

转载自blog.csdn.net/asmartkiller/article/details/88691855