C++11实现一个cyclic barrier

本文介绍了Cyclic-Barrier(循环屏障)在并发编程中的作用,它如何通过计数器同步线程,并与latch进行对比,探讨了它们在使用场景、计数机制和功能上的差异。通过实例演示了Cyclic-Barrier在团队协作和团购场景中的应用。
摘要由CSDN通过智能技术生成

上文中使用计数器作为同步事件实现了latch,其实在多线程并发编程实践中,还有一种使用计数器作为同步事件的机制:Cyclic-Barrier,即循环屏障的意思。它指定了参与执行线程的数量,当某个线程运行到指定位置时就处于等待状态,只有当这些线程全部都到达该位置时,它们才能一起从等待状态中退出。它的实现原理也是以一个计数器作为同步变量,当某个线程到达指定的位置时,就让计数器的值减少1,如果结果不为0时,线程就进入等待状态,只有当计数器减到0时,即指定数量的线程全部到达了指定位置,此时所有在该位置等待的线程全部被唤醒,然后执行各自后续的流程。

下面是cyclic_barrier类的定义:

class cyclic_barrier {
	mutable std::mutex m;
	std::condition_variable cv;
	const int parties;
	volatile int count;
	volatile bool broken;
	volatile int cycle; // 第几轮循环 
	std::function<void(void)> callback;

public:
	cyclic_barrier(int n);
	cyclic_barrier(int n, std::function<void(void)> f);
	~cyclic_barrier() {}
	
	cyclic_barrier(const cyclic_barrier&) = delete; 
	cyclic_barrier& operator=(const cyclic_barrier&) = delete;
	
	void wait();
	int get_waitings() const;
	int get_parties() const;
	void cancel();
};

既然线程有需要等待唤醒、通知的机制,使用互斥量m和条件变量cv就是不二之选了,使用它们进行线程的等待和唤醒操作。
为了进行计数,还需要一个整型的数据成员count来存放计数器,每当某个线程进入同步点时,就让count减1,直到为0;因为要循环计数,还需要保存计数器的初始值parties,它是参与线程的个数,初始化后就不会修改了,使用const修饰。
回调函数callback用于当最后一个线程到达后唤醒其它线程前,所执行的操作。
此外还有用于标记中断的broken变量,线程被唤醒后会检查该值,判断是否是被提前中断了。

看一下各个成员函数的实现。
1、构造函数

cyclic_barrier::cyclic_barrier(int n) : parties(n), cycle(1), callback([](){}) {
	if (n <= 0) {
		throw std::string("invalid parameter!");
	}
	
	count = n;
	broken = false;
}

cyclic_barrier::cyclic_barrier (int n, std::function<void(void)> f) : cyclic_barrier(n) {
	callback = move(f);
}

构造函数有两个,如果不需要回调函数的话,可以使用第一个构造函数来创建对象,它指定了一个缺省实现的回调函数。 如果有自定义的回调函数,可以使用第二个构造函数来创建对象。参数n来指定参与者线程的数量,该参数必须大于0,否则直接抛出异常,构造失败。

2、wait函数

void cyclic_barrier::wait() {
	std::unique_lock<std::mutex> ul(m);
	int n = cycle;
	if (--count == 0) {
		callback(); // callback是在临界区内执行
		cv.notify_all();
		count = parties; // 复位计数器
		cycle++; // 下一轮开始 
		return; // 如果是最后一个到达的线程,显然条件满足了,就直接返回
	}

	// 新一轮开始或者被打断了,才会被唤醒
	while (n == cycle && !broken) {
		cv.wait(ul);
	}

	if (broken) {
		throw broken_exception();
	} 
}

每当有一个线程调用了wait(),就让count减1,并检查count是否为0, 如果不为0,说明还有别的线程没有到达,就进入等待状态;如果为0,说明这些线程全部到达,就先调用回调函数,然后唤醒其它所有等待中的线程。因为是循环barrier,在count为0时,重新设置为parties,等待下一轮的线程。

回调函数执行完之后再唤醒其它线程,这样,如果在回调函数中修改了共享变量,可以保证在其它线程唤醒之前修改完成,以保证共享变量的访问安全。

wait()函数不妨看作是一个屏障(barrier),线程到达此位置时,都不能越过它,只能处于等待状态,直到最后一个线程到达。既然是屏障,那么线程在wait()之前所做的操作,如果涉及到共享变量的话,它们对共享变量所作的修改,也就都happen-before这个屏障之前,从而当所有线程从wait()中唤醒后,进行后续操作时,如果访问这些共享变量,都是发生在这个屏障之后,从而保证了这些共享变量的整体happen-before语义。同样,当最后一个线程到达,调用回调函数也不会越过wait(),如果在回调函数中也有修改共享变量的操作,这个操作同样也happen-before于线程唤醒后的后续操作。

线程在等待过程中也可以被打断,也就是说,即使还没有规定数量的线程调用wait(),也可以强制中断它。程序中使用broken作为中断标记,中断后它被设置为true,线程被唤醒后,如果发现broken=true,则说明是被中断唤醒的,此时线程就抛出broken异常,通知调用者。

4、中断函数

void cyclic_barrier::cancel() {
	std::lock_guard<std::mutex> lg(m);
	broken = true;
	cv.notify_all();
}

如果想要提前中断barrier,通过设置中断标记broken为true,并唤醒全部处于等待中的线程即可,此时线程从wait()中被唤醒后会抛出broken_exception异常。

class broken_exception {
};

3、其它函数

int cyclic_barrier::get_waitings() const {
	std::lock_guard<std::mutex> lg(m);
	return parties - count;
}

int cyclic_barrier::get_parties() const {
	return parties;
}

get_waitings()用来查询处于等待中的线程个数,get_parties()用来查询参与者的线程数量,它们都是const成员函数。

与latch相比,它们有如下特点:
1、cyclic_barrier的计数器统计的是到达屏障点的线程数量,即计数器的初始值是参与者线程的个数;而latch的计数器是根据所要满足的条件而设置的的数量,和线程数量不一定相关。

2、cyclic_barrier的计数器减少到0后,它会被复位,重新设置为初始化时的值,可以被循环使用;而latch是一个一次性的事件,当计数器变为0之后,就不再使用了。

3、使用cyclic_barrier的线程进入等待和唤醒都是同一个函数:wait(),线程在执行过程中调用它时,相当于在该函数的调用位置处设置了一个屏障点,先到达的线程会在屏障点等待后到达的,只有在所有线程全部到达这个屏障点之后,它们才能同时越过这个屏障点;而latch一般是由两种不同性质的线程相互协作,使用了等待和通知机制,一种统一在某个位置等待(调用wait()),另一种通过检查计数器为0时来通知唤醒(调用countdown())。

4、cyclic_barrier有回调函数,在唤醒所有线程之前,可以执行这个回调函数。

示例:
1、多个线程从同一个位置同时运行。

void test(cyclic_barrier & barrier) {
	std::cout << "ready:" << std::this_thread::get_id() << std::endl;
	try {
		// .. 线程运行的准备工作,准备完毕之后,等待同时启动! 
		barrier.wait(); // 等待同时启动 
	} catch (broken_exception &ex) {
		std::cout << "barrier broken!" << std::endl;
	}

	std::cout << "startup:" << std::this_thread::get_id() << std::endl;
	std::this_thread::sleep_for(std::chrono::seconds(1));
	std::cout << "finish:" << std::this_thread::get_id() << std::endl;
}

int main() {
	cyclic_barrier barrier(11, []{
		std::cout << "reach point" << std::endl;
	});

	std::vector<std::thread> vec;
	for (int i=0; i<10; i++) {
		vec.emplace_back(test, std::ref(barrier));
	}

	std::this_thread::sleep_for(std::chrono::seconds(2));
	std::cout << "waiting thread:" << barrier.get_waitings() << std::endl;
	barrier.wait(); // 主线程最后到达,通知所有线程一起运行 
	
	for (auto &t : vec) {
		t.join();
	}
}

2、模拟一个团购场景,任意三个成员就可以组成一个团购机会,有10个成员可以组成3个团,剩余1个无法组团。当活动结束后,取消团购活动。

void test(cyclic_barrier& barrier, int time) {
	std::this_thread::sleep_for(time*std::chrono::seconds(1));
	try {
		barrier.wait(); // 等待组团 
	} catch (broken_exception &ex) {
		std::cout << "sorry! game over" << std::endl;
		return;
	}

	std::cout << "join member:" << std::this_thread::get_id() << std::endl; // 哪个成员加入组团 
}

int main() {
	int n = 0; // 第几次组团成功
	cyclic_barrier barrier(3, [&n]() { //  每有三个成员就可以进行团购 
		std::cout << "reach group: " << ++n << std::endl; 
	});

	srand(time(nullptr));
	std::vector<std::thread> vec;
	for (int i=0; i<10; i++) {
		int times = rand() % 10; // 组员随机到达 
		std::cout << times << std::endl;
		vec.emplace_back(test, std::ref(barrier), times);
	}

	std::this_thread::sleep_for(std::chrono::seconds(10));
	std::cout << "waiting member:" << barrier.get_waitings() << std::endl;
	barrier.cancel(); // 活动结束,取消团购,没有组团成功的被中断 
	
	for (std::thread &t : vec) {
		t.join();
	}
}

barrier和latch都使用了计数器倒计数进行统计,它们的应用场景不太一样,为了便于区分它们,举两个生活中例子,可以体会一下它们的区别。
假如有5个好基友(即线程),商量好周末一块去风景区爬山,他们约定好周日上午在山脚下集合,不见不散。周日那天,如果第一个先到了(调用wait),发现没有其他人到达(count>0),就只好等着(cv.wait),第二个人到了之后,发现人还没有到齐,也只好等待,直到第5个人到达后,即所有5个人全部到齐了(count=0),做完准备工作之后(调用回调函数),就一起出发开始爬山(线程全部唤醒)。我们可以想象在山脚下立着一个栅栏(Barrier),如果它不放倒,人们无法翻越过去,而它被放倒的条件是,5个基友全部到达。如果这5个基友哪怕仅有一个还没有到达,它也不会被放倒,基友就被拦在外面,只有当5个基友全部到达之后,这个栅栏才会倒下,他们就可以进入山门了。这描述了应用barrier的场景。

如果换成另一种场景,这几个基友约定谁先来谁先爬,假设风景区大门的门闩(Latch)上共有2把锁(latch的数量),钥匙分别由门卫和值班经理保管,第一个基友来的比较早,此时大门还没有开,那么他就只能等这2个掌管钥匙的人员来开门:门卫来上班了,把他掌管的那把锁打开(倒计数减1),基友继续等待;当值班经理上班后打开了第二把锁之后(倒计数为0),这个基友进入大门开始爬山(从等待中被唤醒)。此后,其它基友陆续到达后,就无需等待了,因为所有的锁都已经打开了,他们可以直接进入景区爬山。这是应用latch的场景。

C++20中提供了类似的功能,详见barrier类。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值