C++中有关多线程和并发相关知识点梳理



1. 线程:

(1). 创建线程:
void f1();
void f2(int);
void f3(std::vector<int>&);

class A
{
public:
	void fa1(std::vector<int>&);
	operator()(std::vector<int>&);	
}

void ff(A);

//...

std::vector<int> vi;
A a;

std::thread t1(f1);
std::thread t2(f2, 5);

// 形参使用引用传入
std::thread t3(f3, std::ref(vi);

// 函数调用的方式:A* p = &a; p->fa1(vi);
std::thread t4(&A::fa1, &a, std::ref(vi));

// 函数调用的方式:A temp = a; temp.fa1(vi);
std::thread t5(&A::fa1, a, std::ref(vi));

// 函数调用的方式:a.fa1(vi);
std::thread t6(a, std::ref(vi));

// 函数调用的方式:A temp(); temp.fa1(vi);
std::thread t7(A(), std::ref(vi));

// 注意,这种情况下要求A类型不能显式地将移动构造函数指定为
// 删除的;如果没有显式地定义移动构造函数,则必须确保
// 拷贝构造函数可用。
// 此处会将a用拷贝的方式传入t8线程的线程栈中,然后再通过移动(如果可以用移动)或拷贝到函数中,所以会调用两次A的构造函数。
std::thread t8(ff, a);

// 通过std::async也能创建线程,并且可以把线程函数的返回值存储到std::future中,
// 下文会有说明
std::future<int> result = std::async(ff, a);


(2). 线程分离和等待:

注意,使用std::thread创建线程后,一定要确保从它定义的作用域出去的任何路径,使其成为不可加入状态(包括正常退出或者异常退出),不然此对象析构时会抛出异常。一种方式是创建后立即加入或者分离,另一种方案是使用RAII方法(资源获取即初始化技术)。

此外,如果将std::thread作为一个类的成员变量,则应该放到成员列表的最后声明。并且,如果在此类的析构函数中调用join(),则可能导致难以调试的性能异常(因为是阻塞函数,析构时导致线程阻塞。一种方式是通过条件变量来跟异步运行的线程进行通信,当不需要运行的时候,就提前结束异步线程,这样join()就不会再阻塞了)。而如果在析构时调用detach()的话,可能导致难以调试的未定义行为(所以不应该在析构函数中detach)。所以,使用std::thread时,或者std::thread作为一个类的成员变量时,一定要注意:

  • 确保std::thread对象所在的所有退出路径上都执行了join()detach(),使其成为不可join的状态;
  • 在自定义类析构时调用join()可能导致难以调试的性能异常;
  • 在自定义类析构时调用detach()可能导致难以调试的未定义行为;
  • 在成员列表的最后声明std::thread对象。
std::thread t1(f);
std::thread t2(f);

// join()函数使当前线程发生阻塞,直到创建的线程的函数退出,
// 此处join()函数才会返回。
// 如果t1离开作用域析构了,t1的join或detach还未执行,
// 则会引发异常。
// 此外,join和detach只能执行一次。
t1.join();

// detach()使线程分离,子线程此时独自运行。
// 注意,这种方式最好只访问此子线程自己的本地变量。
t2.detach();

// 可以使用joinable来判断此线程是否可以join或者detach
if( t1.joinable())
{}


(3). 线程赋值:

线程只能移动,不能赋值。移动后,原来的线程对象就不与任何线程关联,也就不能joindetach。此外,不能通过将一个线程移动给一个已有的线程来"丢弃"一个线程。系统会直接调用std::terminate()来终止进程,且不抛出异常。例如:

std::thread t1(f1);
std::thread t2(f2);

// 这里由于t2已经有关联的线程了,因此不能做此操作,会崩溃。
t2 = std::move(t1);


(4). 获取系统支持的并发数:

通过函数std::thread::hardware_concurrency()可以获取当前硬件的可并发数量,一般就是cpu的逻辑处理器的个数。如果获取不了,则返回0。

(5). 线程ID:

线程ID用std::thread::id来存储。可以通过thread对象的get_id()或在当前线程中调用std::this_thread::get_id()来获取。线程ID对象可以用来排序和比较,也可以作为关联容器(有序或无序)的key值。



2. 锁、互斥量:

(1). 互斥量作为类成员时:

如果一个互斥量作为一个类的成员,则应该需要把此成员设为mutable类型,因为上锁或解锁都是修改操作。


(2). std::lock_gurad:

std::lock_gurad模板类存储一个互斥量,并上锁,在析构时解锁。不过此类单个实例只能存储一个互斥量。

std::mutex mt;

// 自动上锁
std::lock_guard lk1(mt);

// 只创建对象,不上锁
std::lock_guard lk2(mt, std::adopt_lock);


(3). std::lock():

模板函数,可以同时锁住多个互斥量,且至少是两个(不能锁一个),这样可以一定程度上消除死锁的可能性。std::lock()只能锁定互斥量,不能解锁互斥量,所以一般会与std::lock_guard配合使用。

std::mutex mt1;
std::mutex mt2;

// 先锁定两个锁,再将两个锁分别存入两个lock_guard对象中
// 记住,此种情况下,lock_guard需要传入adopt_lock
std::lock(mt1, mt2);
std::lock_guard lk1(mt1, std::adopt_lock);
std::lock_guard lk2(mt2, std::adopt_lock);
......


(4). std::scoped_lock:

C++17标准中的类,这个类同时具有std::lock()std::lock_guard的功能,它可以同时锁定多个互斥量,并能在析构时解锁所有互斥量。

std::mutex mt1;
std::mutex mt2;

// 同时锁定多个互斥量,并在析构时解锁所有互斥量
// 等同于lock()和lock_guard的组合功能
std::scoped_lock(mt1, mt2);


(5). std::unique_lock:

std::unique_lock的一个实例只能锁定一个互斥量,与std::lock_guard功能有点类似,能够管理互斥量。与std::lock_guard不同的是,std::unique_lock锁住之后,还可以解锁,也还可以重新上锁,并且可以转移锁的所有权,而std::lock_guard就不能够重新解锁或重新上锁,也不能转移所有权(因为std::lock_guard的拷贝构造函数和拷贝赋值运算符以及移动构造函数和移动赋值运算符都是删除的,而std::unique_lock则可以移动,但不可拷贝)。std::unique_lock经常与std::condition_variable搭配使用。

std::mutex mt1;
std::mutex mt2;
std::mutex mt3;
std::mutex mt4;

// 创建对象并锁住mt1
std::unique_lock uk1(mt1);

// 同上
std::unique_lock uk2(mt2, std::adopt_lock);

// 创建对象,但不锁定mt3
// 此时,可以通过uk3.lock()或者将uk3传给std::lock()来重新锁定
std::unique_lock uk3(mt3, std::defer_lock);

// 创建对象,并尝试锁定mt4
std::unique_lock uk4(mt4, std::try_to_lock);

转移所有权的例子:

std::unique_lock<std::mutex> get_lock()
{
	extern std::mutex some_mutex;
	std::unique_lock uk(some_mutex);
	prepare_data();
	return uk;
}

void process_data()
{
	auto uk(get_lock());
	
	// 此函数仍然在锁的保护下,std::unique_lock可以转移所有权,转移到上层 
	do_something();
}


(6). 保护共享数据的初始化过程:

对于像单例模式这样的数据初始化过程,可能会受到多线程数据竞争的影响。
例如:

std::shared_ptr<res> res_ptr;

void foo()
{
	if (!res_ptr)
		res_ptr.reset(new res);
	res_ptr->do_something();
}

如果多个线程执行此函数,则就有可能造成数据竞争。
一种不理想的加锁方案是:

std::shared_ptr<res> res_ptr;
std::mutex mt;

void foo()
{
	std::unique_lock uk(mt); // 1
	if (!res_ptr)
		res_ptr.reset(new res);
	uk.unlock();
	res_ptr->do_something();
}

上面这种方案虽然可以解决数据竞争,但是会影响效率,因为即使智能指针已经初始化一遍了,后面调用此函数还是要加锁。此外,"双重检查锁模式"也可能会造成数据竞争,例如:

std::shared_ptr<res> res_ptr;
std::mutex mt;

void foo()
{
	if (!res_ptr) // 1
	{
		std::lock_guard lk(mt);
		if (!res_ptr) // 2
		{
			res_ptr.reset(new res); // 3
		}
	}
	
	res_ptr->do_something(); // 4
}

上面这种方式可能造成数据竞争,因为有可能线程A在3处正在构建智能指针,还未完全构建完,此时指针可能已经不为空了。如果此时线程切换到B线程,并执行1处,则发现条件判断失败,则会直接执行到4。又由于智能指针还未构建完全,所以就会出错。
有相关的论文来描述"双重检查锁模式"出现问题的原因,比如论文《C++ and the Perils of Double-Checked Locking》

C++新标准解决方法是使用标准库的std::once_flagstd::call_once()

std::shared_ptr<res> res_ptr;
std::once_flag res_flag; // 1

void init_res()
{
	res_ptr.reset(new res);
}

void foo()
{
	// 可以完整的进行一次性初始化
	std::call_once(res_flag, init_res); // 2
	
	res_ptr->do_something();
}

上述实现就能解决这种问题了。
此外,C++11标准之前的C++中,对于局部静态变量的首次初始化过程(即构造过程),也会产生类似的数据竞争。不过,C++11标准之后,已经不会产生数据竞争了,此后的局部静态变量一定是在某个线程初始化完成后,才会被其他线程使用。


(7). 读写锁:

读写锁就是多个线程可以同时对一个共享数据进行读取操作,但是同一时刻只能有一个线程对数据进行修改操作。当在读取时,那唯一的一个写线程会被阻塞,当修改时,所有的读线程都会被阻塞。
读写锁是由读锁(由共享锁实现)和写锁(由排它锁实现)共同构成的。使用std::shared_mutexstd::shared_lock组合能够实现共享锁,std::shared_mutexstd::lock_guard能够实现排他锁:

std::vector<int> vi;
std::shared_mutex smt;

int read(std::size_t index)
{
	std::shared_lock sk(smt);
	return vi.at(index); // 自带下标范围检查
}

void write(int value)
{
	std::lock_guard lk(smt);
	vi.push_back(value);
}

这样,可以同时有多个线程来调用read()函数而不会阻塞(不过此时write()函数阻塞的)。如果正在调用write(),则所有调用read()的线程都会阻塞。
注意,由于shared_mutex不是嵌套互斥体,所以不能同一个线程多次嵌套调用这类锁,嵌套锁必须用对应的std::recursive_mutex来实现。



3. 线程间数据同步与并发:

(1). 条件变量 std::condion_variable:

标准库中的std::condition_variablestd::condition_variable_any都需要与一个互斥量在一起才能工作(互斥量是为了同步)。其中,std::condition_variable只能与std::mutex一起工作,而std::condition_variable_any可以和任何满足最低标准的互斥量一起工作。不过,后者要比前者效率低。因此,一般使用前者。
此外,std::condition_variable一般与std::unique_lock<std::mutex>一起,用于等待条件触发:

std::vector<int> vi;
std::mutex mt;
std::condition_variable cv;

void write(int value)
{
	int i = 0;
	while(std::cin >> i)
	{
		std::lock_guard lk(mt);
		vi.push_back(i);
		cv.notify_one(); // 1 发出通知
	}
}

void read()
{
	// 这里一定要用std::unique_lock,不能用lock_guard
	std::unique_lock uk(mt);
	cv.wait(uk, !vi.empty()); // 2 等待通知
	
	int v = vi.back();
	vi.pop_back();
	
	uk.unlock(); // 下面的IO操作是耗时操作,所以释放锁
	std::cout << v << '\n';	
}

write线程准备好数据后通过notify_one()通知正在等待wait()的线程。read函数中第一次执行wait()时并不需要通知,其会先检测后面的谓词是否成立,如果成立,则wait()直接返回,如果不成立,则wait会释放锁,并阻塞。详细情况请查看C++中条件变量std::condition_variable的唤醒说明


(2). 期望 std::future和std::shared_future:
(a). std::future:

C++标准库将一次性事件的结果称为期望值std::future能够存储由其他线程返回过来的值,并能够等待这个值准备就绪(没准备就绪就会发生阻塞),所以其可以用来访问异步操作的结果,例如:

int delay_add(int a, int b)
{
	std::this_thread::sleep_for(std::chrono::seconds(2));
	return a + b;
}

// 使用std::async创建一个线程,并立即启动
// 参数std::launch::async表示线程创建后立即启动
//(但是编译器可能会优化,如果函数执行很快,可能即使指定了此参数,也会到下面的get()调用时才会启动子线程)
std::future<int> fu = std::async(std::launch::async, delay_add, 4, 6);
......

// 获取期望值,此值就是上述线程的返回值。
// get()函数是一个阻塞函数,如果上述子线程未执行完,则此处会发生阻塞。
// 此外,如果子线程里面抛出了异常,则在此get()函数处会触发异常,
// 可以通过try catch获取
int v = fu.get();

......

std::async()能够启动一个线程,并将线程函数的返回值存储在一个std::future对象中,可以通过get()函数来获取此返回值。std::async()既可以选择立即启动一个线程(通过传入参数std::launch::async),也可以显式指定新建的线程直到调用std::future对象的get()函数或wait()函数时才启动(通过传入参数std::launch::deferred),当然也可以不指定这两个参数。如果不指定的话,则程序会根据情况自动选择其中的一种启动方式。需要注意的是,如果新线程函数足够简单,不耗时,则即使传入std::launch::async,新的线程也可能不会立即启动。此外,如果使用的是非异步启动(比如指定了std::launch::deferred或者使用默认参数但系统选择非异步调用时),则std::future的成员函数wait_for()wait_until()始终返回std::future_status::defered,此时下面的循环可能永远无法返回:

void f() {......}
auto fut = std::async(f);

using namespace std::literals;

// 这里判断可能永远不会成立,一旦系统默认使用的启动策略,无法得知其到底使用哪种方式启动
while (fut.wait_for(100ms) != std::future_status::ready)
{......}

所以使用的时候一定要注意(特别是std::async使用默认启动策略的时候)。这种情况一般可以通过事先主动询问其是否被推迟执行来解决:

.....
auto fut = std::async(f);

// 判断任务是否真的被推迟了
if (fut.wait_for(0s) == std::future_status::deferred)
{......}
else
{
	// 在确定是并发执行的情况下等待
	while (fut.wait_for(100ms) != std::future_status::ready)
	{......}
}

此外,std::async()使用默认策略启动也会影响thread_local的使用。因此,一般情况下只有下述几个条件全部满足时才使用默认策略:

  • 任务不需要与调用get()wait()的线程并发执行;
  • 读/写哪个线程的thread_local变量并无影响;
  • 要么能够保证一定会在std::async返回的期望值上调用get()wait(),要么允许任务可能永远无法执行(因为如果是非异步执行的话,有可能某个条件分支导致不会获取返回值,此时任务就不会得以执行);
  • 使用wait_for()wait_until()的代码会将任务被推迟的可能性加以考虑。

如果上述条件有一个不满足,那么你就可能要明确使用异步的方式来运行任务了。其实可以对std::async进行包装,产生一个一定会异步执行的任务:

// C++11 版本
template<typename F, typename... Args>
inline std::future<typename std::result_of<F(Args...)>::type>
reallyAsync(F&& f, Args... params)
{
	return std::async(std::launch::async,
					  std::forward<F>(f),
					  std::forward<Args>(params)...);
}

// C++14或以上版本
template<typename F, typename... Args>
inline auto
reallyAsync(F&& f, Args... params)
{
	return std::async(std::launch::async,
					  std::forward<F>(f),
					  std::forward<Args>(params)...);
}

(b). std::shared_future:

注意,std::futureget()只能够执行一次,执行完后,状态就变为不可用状态,因此std::future不能用于多个线程来获取其值。如果想这么做,可以用std::shared_futurestd::shared_future可以多次get()。需要特别注意的是,如果多个线程对同一个std::shared_future对象执行get(),则也可能会产生数据竞争,此时要么加一个锁,要么将std::shared_future拷贝成多个副本,每个线程一个副本。这样,每个线程再分别获取各自的std::shared_future副本的值时,就不会发生数据竞争了,并且其获取的值是同一个值,是同步的。
此外,可以直接将std::future转成std::shared_future。注意,一旦std::future通过任意形式转成了std::shared_future,那么原std::future就不再可用了,对其调用valid()函数就会返回false

int delay_add(int a, int b)
{
	std::this_thread::sleep_for(std::chrono::seconds(2));
	return a + b;
}

std::future<int> fu = std::async(std::launch::async, delay_add, 4, 6);

// 可以通过移动构造函数直接传入std::future来构造一个std::shared_future
std::shared_future<int> sfu1(std::move(fu)); // 1

// 也可以直接用share()函数转换,推荐这种做法,因为这样就可以用auto自动推导了
// 注意,这里严格来说是错误的,只是为了展示用法,因为上面已经把fu转成sfu1了,
// 所以fu对象已经失效了,不能再用了
auto sfu2 = fu.share(); // 2

// 拷贝一份副本
auto sfu3 = sfu2;

推荐代码中的2处的转换方式。

另外,要注意,如果std::futurestd::shared_future是由std::async()返回得到的对象,且使用的是std::launch::async策略执行的,则此std::future对象析构的时候可能会被阻塞(就类似与执行了join()),如果是std::shared_future对象,则当指向同一个共享状态的最后一个std::shared_future对象析构时,也会阻塞(其他的std::shared_future不会阻塞,正常析构)。如果不是由std::async()创建的期望(或使用了std::launch::deferred策略),则期望被析构的时候并不会做任何动作,只是把期望的数据成员销毁(不会join也不会detach,只是析构期望的成员变量和对引用计数减1。不过其针对并发中的线程相当于执行了隐式detach,因为并未阻止线程的执行,也未等待其执行完成。而针对那些还未执行的线程,比如使用了std::launch::deferred策略,则期望析构就表示这些函数不会被执行了)。所以,这就导致可能出现很难发现的性能问题(看注释):

// 该容器的析构函数可能会在其析构函数中阻塞,因为它所持有的期望中可能
// 是由std::async(std::launch::async, ...)返回的
std::vector<std::future<void>> futs;

class Wdiget
{
	public:
	......
	
	private:
	std::shared_future<double> fut; // 此Widget对象可能会在其析构函数中阻塞
}


(3). std::promise:

std::future可以用于多个线程间传输数据,然而上面都是通过线程的返回值来传输数据,可操控性不够强,如果能够手动设置值并通过std::future来传输就好了,std::promise正是如此。其实std::promise是对std::future的更高一级的封装。

void thread1(std::promise<int> ps)
{
	try
	{
		// 设置值
		ps.set_value(7);
	}
	catch(...)
	{
		// 设置异常
		// std::current_exception()自动获取系统当前异常
		ps.set_exception(std::current_exception());
		
		// 或者使用std::make_exception_ptr设定已知异常
		// ps.set_exception(std::make_exception_ptr(std::logic_error("Test!!!")));
	}
}

void mainfunc()
{
	std::promise<int> ps;
	
	// 获取std::future
	auto fu = ps.get_future();
	
	// 将std::promise传入子线程,只可移动,不可复制
	std::thread t(thread1, std::move(ps));
	t.detach();
	
	try
	{
		int i = fu.get();
		std::cout << i << '\n';
	}
	catch (const std::exception& e)
	{
		// 如果子线程中有异常,会将异常传出来到get()函数这里
		// 这个异常有点延迟抛出的意思,延迟到调用get()时才抛出
		std::cout << "error: " << e.what() << '\n';
	}	
}

std::promise不仅可以设置值,也可以设置异常,其会将异常传到std::futureget()处。
此外,假如std::promise对象析构时还未调用过相关的set...函数(即期望值的状态还未就绪),则系统会自动将一个异常存储到对应的std::future中,此异常的类型为std::future_error,值为std::future_errc::broken_promise

使用std::promise可以实现类似与std::condition_variable那样的线程间同步:

std::promise<void> psv; // 单纯的同步使用void类型即可

void rect();
void detect()
{
	// 这里由于使用的是std::future,所以只能实施一次唤醒操作,然后就不能用了,并且只能唤醒一个线程
	std::thread t([]{psv.get_future().wait(); rect();}); 

	// 如果想同时可以唤醒多个线程的话,就使用std::shared_future,并且复制成多份给各自线程,不过还是只能唤醒一次
	auto sf = psv.get_future().share(); // 创建std::shared_future
	std::thread t([sf]{sf.wait(); rect();}); // 注意这里sf是按值捕获的
	
	...... // 这里如果抛出异常的话,则线程t将会永远无法醒来
	
	psv.set_value(); // 通过设置共享状态,从而间接的唤醒线程t
	
	......
	
	t.join();
}

不过这种方式只能唤醒一次线程,或者说只能进行一次性通信,并且由于std::futurestd::promise是用的共享状态来通信的,而共享状态是在堆内存上创建的,所以会带来堆上的分配和回收成本。如果想进行多次通信的话,还是使用常规的std::condition_variable吧。


(4). std::packaged_task:

std::packaged_taskstd::function类似,都是存储可调用对象的。它本身也可以作为可调用对象,传给std::thread或者std::function。此外,std::packaged_task能够将可调用对象的返回结果存储到内部的std::future中,用于异步获取。因此,std::packaged_task也算是对std::future更高一层的封装。std::packaged_taskstd::async不一样,本身无法异步执行,它只是提供一套机制,让std::packaged_task的可调用对象在另一个线程运行时,本线程能够得到此可调用对象的返回值。std::packaged_task一般与std::thread一起使用。

int add(int a, int b)
{
	std::this_thread::sleep_for(std::chrono::seconds(2));
	return a + b;
}
 ......

int main()
{
	// 模板类型是函数签名,构造函数中无需传入可调用对象的实参
	std::packaged_task<int(int, int)> task(add);
	
	// 获取共享状态,std::future
	auto fu = task.get_future();

	// 将std::packaged_task对象作为可调用对象传给std::thread,只能移动,不能拷贝
	std::thread t(std::move(task), 4, 5);
	t.detach();

	// 获取异步结果
	auto v = fu.get();
}

上述完全可以用std::async()来实现,std::packaged_task的作用是可以用于诸如线程池的开发,如果只是简单的,则使用std::async()就行了。

另外,从std::packaged_task中获得的期望对象析构时候,并不会阻塞,此时要关注的是对应的std::thread对象要在合适的地方执行join()detach()



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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值