C++并发编程(三)线程间共享数据

目录

前言

用互斥保护共享数据

接口设计

死锁

防范死锁的准则

灵活加锁

互斥归属权的转移

合适的加锁粒度

共享数据初始化的保护

 保护较少更新的数据

递归加锁

总结


前言

在线程之间共享数据,我们需要关注:具体哪个线程用什么方式访问了什么数据;数据改动后,如果牵涉到其它线程,它们要在何时以何种方式获得通知。同一个进程内的多个不同的线程不正确地使用共享数据,容易产生错误。当然只读数据不会造成这些影响。

不变量是针对某特定数据的断言,该断言总是成立,如:“这个变量的值就是链表元素的数目。”数据更新往往会破坏这些不变量。改动线程数据,最简单的问题是破坏不变量,假如某线程正在读取双向链表,另一线程同时在删除节点,在缺乏安全措施的情况下,执行读取操作的线程可能遇见没完全删除的节点(只改变了前面的正向指针,后面的逆向指针还没改),不变量遂被破坏。若此时另一线程同样需要修改链表,容易造成链表损坏。

并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争race condition)。

防止恶性条件竞争的方法:

1.不变量被破坏时,中间状态只对执行改动的线程可见。

2.无锁编程,修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持变量不被破坏。

3.修改数据结构当作事务(transaction)来处理,把需要执行的数据读写操作视为一个完整的序列,先日志存储记录,再把序列当成单一步骤提交运行。若别的线程改变了数据令提交无法完整执行,则事务重新开始。称为软件事务内存(Software Transactional Memory, STM)。

用互斥保护共享数据

运用名为互斥(mutual exclusion,略作mutex)的同步原语(symchronization primitive)来实现数据互斥:某线程访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁。其它线程必须等待该线程解锁,才能给它加锁。这样确保了除了正在改动数据的线程,其它线程无法看见不变量被破坏,形成自洽(self-consistent)的共享数据。

在<mutex>头文件中,std::mutex类的lock()成员函数可以对线程加锁,unlock()解锁,标准库提供了类模版std::lock_guard<>针对互斥类实现了RAII手法,在构造时加锁(调用模板类的lock()),在析构时解锁(调用模板类的unlock())。

#include <vector>
#include <mutex>
#include <algorithm>

std::vector<int> v;
std::mutex m;

void add(int val)
{
	std::lock_guard<std::mutex> guard(m);
	v.push_back(val);
}

bool contains(int val)
{
	std::lock_guard<std::mutex> guard(m);
	return std::find(v.begin(), v.end(), val) != v.end();
}

以上例子在add()和contain()两个函数中创建了std::lock_guard对象,使得两个函数对链表的访问互斥。C++17引入了类模版参数推导(class template argument deduction),可以省略std::lock_guard的模版参数:“std::lock_guard guard(m)”。

一般会创建一个类,将受保护的数据结构作为其数据成员,并在成员函数中使用访问互斥。

注意事项:但是如果成员函数返回指针或引用,指向受保护的共享数据,那么只要存在访问该指针或引用的代码,就能访问或修改受保护的数据,无须锁定互斥;如果成员函数内部调用了其它不受掌控的函数,并传入了共享数据的指针或引用,同样危险。所以设计接口需要注意是否存在此类漏洞,我们应该遵循:不得向锁所在作用域之外传递指向受保护数据的指针和引用。

接口设计

让线程在不同数据上运行相同代码是常用的提升性能的方法,共享的栈容器是理想的工具。

stack<int> s;
if (!s.empty())
{
	const int value = s.top();
	s.pop();
	do_something();
}

在栈结构中,如果使用多线程处理栈数据,并涉及数据共享,这一连串调用并不安全:在empty()和top()之间,可能有另一个线程调用了pop(),在空栈上调用pop()导致未定义行为。

有一种方法是在if语句中捕捉异常,一旦空栈调用top()就抛出异常,这样做即使if语句满足条件,仍然可能抛出异常,if语句就成了优化手段。

假设栈内部有互斥保护,任何时刻只准许单一线程运行其成员函数,函数调用交错有秩,但是top()和pop()之间会存在潜在的条件竞争:

线程A线程B
if(!s.empty())
       if(!s.empty())
       const int val = s.top(); 
const int val = s.top(); 
        s.pop()
        do_something(val) s.pop()
do_something(val)

我们会发现,pop调用了两次,线程B调用pop()弹出的元素未被读取。此时我们会想,如果把top()和pop()合二为一,组合成一个函数,功能是从栈上移除栈顶元素,并返回栈顶元素的值,来解决这一问题。std::stack的设计者考虑到,如果只有栈被改动后,弹出的元素才返回给调用者,在向调用者复制数据的过程中,可能抛出异常导致复制不成功(弹出的元素已从栈上移除),数据会丢失,故把操作分开成两个函数,即便我们无法安全地复制数据,数据还是会留在栈上。

此时我们有几种不完美的方法消除top()和pop()的条件竞争(在函数内部进行数据复制):
1.传入引用。借用一个外部变量接收弹出的元素,以引用pop(int &)的方式传入参数。此方法需要事先构建一个栈元素型别的实例,且要求该型别可赋值(assignable)。

2.元素提供不抛出异常的拷贝/移动构造函数。使用std::is_northrow_copy_constructible和std::is_northrow_move_constructible这两个型别特征(type trait)可以在编译期对某个型别作出判断,但有些类别无法用栈存储。

3.返回指针,指向弹出元素。优点是可以自由复制,且不抛出异常。std::shared_ptr会是不错的选择,系统自动管理内存分配,省去new和delete操作。

我们可以结合方法1和2或1和3。下面的例子分别实现了1和3:

#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack : std::exception
{
	const char* what() const throw();
};

template<typename T>
class threadsafe_stack
{
private:
	std::stack<T> data;
	mutable std::mutex m;
public:
	threadsafe_stack(){}
	threadsafe_stack(const threadsafe_stack& other)
	{
		std::lock_guard<std::mutex> lock(other.m);
		data = other.data;
	}
    
	threadsafe_stack& operator=(const threadsafe_stack&) = delete;//删除赋值运算操作

	void push(T new_val)
	{
		std::lock_guard<std::mutex> lock(m);
		data.push(std::move(new_val));
	}

    //方法一
	void pop(T& val)
	{
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();
		val = data.top();
		data.pop();
	}

    //方法三
	std::shared_ptr<T> pop()
	{
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();
		const std::shared_ptr<T> res(std::make_shared<T>(data.top()));
		data.pop();
		return res
	}

	bool empty()const
	{
		std::lock_guard<std::mutex> lock(m);
		return data.empty();
	}
};

上面的安全栈类可以复制,先在源对象上锁住互斥,再复制内部的std::stack,注意此处不使用初始化列表,保证互斥的锁定会横跨整个复制的过程。

死锁

假设有两个线程,都需要同时锁住几个互斥才能进行某项操作,但他们都分别只锁住了一部分,另一部分被对放锁住,于是双方都在等待对方解锁,此时就会引发死锁(deadlock)。

通常防范的建议是,始终按照一定的顺序对互斥加锁。若某函数的参数是同一个类的两各不同实例,我们始终按函数的参数顺序进行加锁(如先给第一个参数的实例上锁,再给第二个),由于封装特性,用户并不了解内部实现,用户同时调用两个该函数,它们接收参数对应的实例顺序相反,仍会引发死锁。

C++标准库提供了std::lock(),可以同时锁住多个互斥解决这一问题:

class A;
void swap(A& la, A& ra);

class X
{
public:
	X(const A& a):m_a(a){}
	friend void swap(X& lx, X& rx)
	{
		if (&lx == &rx) return;
		std::lock(lx.m, rx.m);
		std::lock_guard<std::mutex> lock_l(lx.m, std::adopt_lock);
		std::lock_guard<std::mutex> lock_r(rx.m, std::adopt_lock);
		swap(lx.m_a, rx.m_a);
	}
private:
	A m_a;
	std::mutex m;
};

上述友元函数swap内先比较了两个输入参数的实例,确保它们指向不同的实例,避免std::lock重复上锁导致未定义行为。然后调用std::lock锁住两个互斥,并分别构造 std::lock_guard实例,构造函数中额外提供std::adopt_lock对象,指名互斥已被锁住,构造函数内不得另行加锁。

假如std::lock已成功上锁其中一个互斥,但在另一个互斥上锁时报错,则第一个锁也会被释放,多个互斥的语义是”全员共同成败“(all-or-nothing或全部锁定,或没有锁定抛出异常)。

C++17提供了RAII类模板std::scoped_lock<>,可以接收各种互斥型别作为模板参数列表,还能以多个互斥对象作为构造函数的参数列表。上述友元函数可以改写:

    friend void swap(X& lx, X& rx)
	{
		if (&lx == &rx) return;
		std::scoped_lock guard(lx.m, rx.m);
		swap(lx.m_a, rx.m_a);
	}

 C++17的隐式类模板参数推导机制,根据传入的参数匹配正确的型别。

防范死锁的准则

有时候即便没有上锁,也会发生死锁现象,比如两个线程分别关联了两个std::thread实例。我们同时获取多个锁可以防范死锁,但若代码分别获取,就需要注意一些规则。

1. 避免嵌套锁。若已经持有锁,就尽量不要试图获取第二个锁,万一需要多个锁,则采用std::lock或std::scoped_lock<>同时对多个互斥上锁。

2.持有锁就必须避免调用用户提供的程序接口。用户提供的接口可能试图获取锁,导致嵌套锁,可能发生死锁。

3.固定顺序获取锁。前文已经提到这种直观的方法,若需要加多个锁,但无法通过std::lock一步获取全部锁,则需要按固定顺序。比如:在链表中,给每个节点配备互斥,线程访问链表就要获取相关节点的互斥锁。删除节点时,必须获取当前节点及相邻两个节点的锁,确保它们不被其它线程改变;遍历链表时,线程必须获取当前节点的锁,同时在后续节点上获取锁,确保前向指针不被改动,一旦获取后续节点的锁,当前节点的锁遂可释放。但是,若两个线程分别在相反方向上遍历链表,在交汇时会发生死锁。此时需要规定遍历方向进行避免。

4.按层级加锁。按特定次序规定加锁顺序。把应用程序分层,明确每个互斥位于哪个层级,若某线程已对低级互斥加锁,则不允许再对高级互斥加锁,具体是将层级编号赋予对应层级应用上的互斥。可惜C++库尚未直接支持这种方式,需要自行编写互斥型别hierarchical_mutex。

class hierarchical_mutex
{
	std::mutex internal_mutex;
	unsigned int long const h_val;//需要上锁的互斥层级(hierarchical value)
	unsigned int long previous_h_val;//记录上一次的层级
	static thread_local unsigned long this_thread_h_val;//当前层级,静态保持在不同函数域中不变

	void check_h_violation()//检查当前层级是否大于要加锁的层级,否 则无法上锁
	{
		if (this_thread_h_val <= h_val)
		{
			throw std::logic_error("Mutex hierarchy violated!");
		}
	}

	void update_h_val()
	{
		previous_h_val = this_thread_h_val;
		this_thread_h_val = h_val;
	}

public:
	explicit hierarchical_mutex(unsigned long val):h_val(val),previous_h_val(0){}
	void lock()
	{
		check_h_violation();
		internal_mutex.lock();//检查完成后加锁
		update_h_val();
	}

	void unlock()
	{
		if (this_thread_h_val != h_val)//避免层级混乱,确保解锁的互斥时最后一个上锁的互斥
			throw std::logic_error("Unlock mutex hierarchy error!");
		this_thread_h_val = previous_h_val;//解锁时层级复原
		internal_mutex.unlock();
	}
	bool try_lock()//与lock相同原理,会返回是否成功
	{
		check_h_violation();
		if (!internal_mutex.try_lock()) return false;
		update_h_val();
		return true;
	}
};
thread_local unsigned long hierarchical_mutex::this_thread_h_val(ULONG_MAX);//初始对象设置层级最高,可对任意互斥加锁

 上述例子中,在内部互斥加锁完成后更新层级,在解锁完成前更新层级,内部互斥保护到位。

以下时简单的应用例子:

hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex mid_level_mutex(5000);
hierarchical_mutex low_level_mutex(1000);

void low_func()
{
	std::lock_guard<hierarchical_mutex> lg(low_level_mutex);
	...
}
void thread_a()//先对高层级互斥high_level_mutex加锁,再调用low_func对低层加锁,可以正常运行
{
	std::lock_guard<hierarchical_mutex> lg(high_level_mutex);
	low_func();
}

void thread_b()//先中层级加锁,再调用线程a,a中需要对高层加锁,运行出错
{
	std::lock_guard<hierarchical_mutex> lg(mid_level_mutex);
	thread_a();
}

 若将此方法应用于单向链表的遍历,则前驱节点的层级必须高于当前节点层级。

灵活加锁

std::unique_lock<>对象与std::lock_guard<>一样,依据互斥作为参数,但其对象不一定始终占据与之关联的互斥,在构造其对象时,第二个参数可以传入std::adopt_lock(管理互斥上的锁)或std::defer_lock实例(在构造时互斥保持无锁状态),我们可以改写之前的代码:

	friend void swap(X& lx, X& rx)
	{
		if (&lx == &rx) return;
		std::unique_lock<std::mutex> lock_l(lx.m, std::defer_lock);
		std::unique_lock<std::mutex> lock_r(rx.m, std::defer_lock);
		std::lock(lock_l, lock_r);
		swap(lx.m_a, rx.m_a);
	}

std::unique_lock实例底层与互斥关联,与互斥同样具有lock、unlock和try_lock成员函数,可以传给std::lock()函数,其实例还有一个内部标志,表明关联的互斥是否被该实例锁占据,保证只有占用互斥时才能进行unlock(),可以通过成员函数owns_lock()查询。因此std::unique_lock<>相比std::lock_guard<>需要占据更多的存储空间,因此若无特殊需求,优先采用std::lock_guard<>。std::unique_lock<>拥有类似互斥的成员函数用于加锁和解锁,故可以在对象销毁前进行解锁,更加灵活。

互斥归属权的转移

std::unique_lock可以不占有与之关联的互斥,所以互斥归属权可以在多个std::unique_lock实例之间进行转移。std::unique_lock可转移不可复制,注意转移时左值(实在的变量或指向真实变量的引用)需要显式调用std::move()。

std::unique_lock<std::mutex> get_lock()
{
	std::mutex m;
	std::unique_lock<std::mutex> ul1(m);
	prepare_data();
	return ul1;
}

void thread_a()
{
	std::unique_lock<std::mutex> ul2(get_lock());//局部变量无需std::move()
	...
}

上述get_lock函负责准备前期工作 ,并把互斥归属权由ul1转移给thread_a的ul2,线程后续进行相关工作,别的线程无法改动互斥保护的数据。

合适的加锁粒度

锁粒度表述一个锁保护的数据量,粒度精细的锁保护少量数据,粒度粗大的锁保护大量数据。锁粒度够大能保证数据得到保护,锁粒度够细则各线程等待时间会减少,因此我们应该权衡安全性与性能,仅仅在必要时才锁住互斥,让数据尽可能不用锁保护。我们可以使用std::unique_lock管理互斥,在需要时lock,不需要互斥加锁时unlock。

下面的例子在比较运算的过程中,每次只对一个对象的数据上锁:

class Y
{
	int val;
	mutable std::mutex m;
	int get_val()const
	{
		std::lock_guard<std::mutex> lg(m);
		return val;
	}
public:
	Y(int value):val(value){}
	friend bool operator==(const Y& ly, const Y& ry)
	{
		if (&ly == &ry) return true;
		const int val_l = ly.get_val();
		const int val_r = ry.get_val();
		return val_l == val_r;
	}
};

在==重载函数中,get_val()函数先锁住一个目标对象,返回其值后,lg销毁解锁,再用get_val()复制另一个对象的值。但我们要注意在单独复制一个值期间,另一个值是否会被其它线程改变引发错误。

共享数据初始化的保护

共享数据的创建需要较多的开销,因为创建它可能需要建立数据库连接或分配大量内存,只有必要时才真正创建,这种方式成为延迟初始化(lazy initialization)。

std::shared_ptr<some_resource> resource_ptr;
std::mutex m;
void foo()
{
	std::unique_lock<std::mutex> ul(m);
	if (!resource_ptr)
	{
		resource_ptr.reset(new some_resource);//reset指针转shared_ptr
	}
    ul.unlock();
	resource_ptr->do_something();
}

上述每个线程都必须在互斥上轮候,等待查验数据是否已经完成初始化。 且在foo()函数刚进入时,若另一线程已上锁,此时该函数阻塞,等到另一线程完成共享指针初始化,解开锁,并调用do_something()操作改动实例,此时foo()才开始执行,由于指针非空,直接调用do_something(),但函数对实例初始值已被改动不知情,会引发错误。

C++标准库提供了std::once_flag类和std::call_once()函数,std::call_once(std::once_flag, callable)接收断言(predicate,又称为“谓词”,在C++语境下,它是函数或可调用对象)作为参数,可以令所有线程共同调用std::call_once()函数,从而确保在该调用返回时,指针初始化由其中某线程安全且唯一地完成(同步机制)。
必要的同步数据会由std::once_flag实例存储,相比显式互斥每次都需要上锁,开销更低。(std::once_flag与std::mutex相似,既不可复制也不可移动)上述函数可以改写为:

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

对于某个类的数据成员,我们也可以用相似的方式安全地进行延时初始化:

class A
{
private:
	B b;
	C c;//假设C类中有send和receive成员函数
	std::once_flag c_init_flag;
	void init_c()
	{
		this->c = open(this->b);
	}
public:
	A(const B& b_):b(b_){}
	void send(const Data& data)
	{
		std::call_once(c_init_flag, init_c, this);
		c.send(data);
	}
	Data receive()
	{
		std::call_once(c_init_flag, init_c, this);
		return c.receive();
	}
};

上述例子中,数据成员c需要特殊的方式init_c()进行初始化,它会在第一次调用send()或receive()中进行初始化,因为在初始化函数中需要用到this指针,故向std::call_once传递this指针作为附加参数。

对于静态数据成员或静态实例,C++11标准了保证线程安全的初始化,可用一个函数代替std::call_once():

class A;
A& get_instance()
{
	static A instance;
	return instance;
}

 保护较少更新的数据

除了在初始化过程中保护数据,保护很少更新的数据更为普遍,它们大多时候处于只读状态,因此可以被多个线程并发访问。我们需要一种新的互斥,允许单个线程“写线程”排他地访问,也允许多个“读线程”并发访问。

C++17中引入了std::shared_mutexstd::shared_timed_mutex,后者支持更多操作(后续章节介绍)。与std::muetx类似,可用std::guard_lock<std::shared_mutex>或std::unique_lock<std::shared_mutex>进行排他锁的锁定,保证数据访问的互斥性。而对于数据的共享性,则改用共享锁std::shared_lock<std::shared_mutex>实现共享访问。

 若别的线程试图获取排他锁,其它共享锁和排他锁的线程都会阻塞。

递归加锁

我们知道,如果某个线程已经持有std::muetx实例,试图对其进行重复加锁会引发未定义错误,但在某些场景中,我们需要让线程在同一互斥上多次重复加锁,而无须解锁。当然,在另一线程访问数据前,加上的锁必须全部完成解锁。假设我们要设计一个支持多线程并发访问的类,它就需要包含互斥来保护数据成员,假设每个公有函数都需要锁住互斥,在一些公有函数内部调用了别的公有函数,别的公有函数同样试图锁住互斥,此时便需要重复锁住互斥。

C++标准库提供了std::recursive_mutex,与上述的std::shared_mutex、std::mutex类似,可以使用std::guard_lock<std::recursive_mutex>或std::unique_lock<sstd::recursive_mutex>实例进行管理。

这种递归加锁的函数设计并不推荐,实际上在嵌套的函数调用中,往往可以提取它们的公共部分作为一个新的私有函数,新函数由这两个公有函数调用,且它假定互斥已被锁住。

总结

std::mutex m 声明互斥对象。

std::lock_guard<> 依据互斥作为参数的类模板,在构造和销毁时,分别执行互斥的加锁与解锁。std::adopt_lock参数可以指名互斥对象已上锁。

std::scoped_lock<> RAII手法管理的类模板,用于同时对多个(可不同类型)互斥上锁。

避免死锁方法:1.避免嵌套锁(需要则同时上锁)。2.上锁后避免调用用户的接口。3.按顺序加锁与解锁(人工方法、使用层级)。

std::unique_lock可以进行互斥归属权的转移,拥有lock、unlock和try_lock成员函数,可进行灵活解锁,构造时可以传入std::adopt_lock,或std::defer_lock保持无锁。

共享互斥:std::shared_mutex。
共享锁实例:std::shared_lock<std::shared_mutex>
排他锁实例:std::unique_lock<std::shared_mutex>

std::call_once(std::once_flag, ...)方法用于数据初始化时,仅由一个线程完成。

递归锁互斥:std::recursive_mutex。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值