【C++】Concurrency & Multithreading

Concurrency

What and Why?

大型软件通常需要同时处理很多任务。
单核环境下,多任务可以同时执行,因此,一个任务不会完全被阻塞而没有响应。
多核环境下,多任务可以在多个不同的核上同时运行,可以提高软件性能。

Multiple processes & Multiple threads

进程(process):指计算机中已运行的程序。

  • 每个进程拥有独立的内存空间,开销大
  • 进程间通信比较复杂
  • 开发难度较大
  • 一个进程crash,不影响其他进程

线程(thread):操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。

  • 多个线程共享同样的内存空间,开销小
  • 线程间通信也复杂,但是相对简单些
  • 开发难度也大,但是相对简单些
  • 一个线程crash,整个进程都会crash

何时不适合并发?
如果并发的代价大于并发带来的好处时,不要实现并发。
并发的代价:

  • 并发会消耗更多的资源
  • 并发会引发更多的线程上下文切换
  • 代码复杂的增加,调试分析难度加大

Multi-threading in C++

Launch a Thread

启动一个线程,将参数传递给一个线程函数:

#include <thread>

void do_a_task(int x) {
	// do something
};

std::thread my_thread(do_a_task, 1);	//参数:函数名,传递进函数的参数
class my_task{
	public:
		void operator()()const{
			// do something
		}
};
my_task f;
std::thread my_thread(f);	//参数:实例对象

等待线程结束:

my_thread.join();
//如果线程结束了,就直接往下运行
//如果线程还在运行,就会等待线程结束后,join()才退出

my_thread销毁时,线程也将被终止并销毁。
对象与线程分离:detach()
将线程与主线程分离,使该线程运行结束后得以终止自己并释放资源。
分离后,my_thread销毁时,线程将不会被销毁,其将在后台独自运行直至主动退出。
分离后的线程将不能被pthread_join等待。

my_thread.detach();

pthread

使用pthread的线程管理。
启动线程:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routione)(void*), void *arg);
//参数:线程id,线程属性
#include <pthread.h>
void* do_a_task(void* param) {
	// do something
	pthread_exit(NULL);	//也可以直接returen
};

pthread_t thread_id;
int rc = pthread_create(&thread_id, NULL, do_a_task, NULL);

等待线程结束:

pthread_join(thread_id, &status); 	//返回值将传到地址中

分离线程:

pthread_detach(thread_id);	//分离后id与线程则不再绑定

销毁线程:

pthread_cancel(thread_id);

async

启动异步线程并行完成独立工作。
std::future 一个对象模板
std::async 一个方法,能创建future对象
future::get() 将等待异步线程完成计算并获得返回结果

#include <future>
#include <iostream>

int find_the_answer() {
	int answer;
	// do task 1
	return answer;
}

int main() {
	std::future<int> the_answer = std::async(find_the_answer);
	// do task 2
	std::cout << "The answer is" << the answer.get() << std::endl;
}
//若大量启动异步线,消耗会非常大

Data Sharing & Race Conditions

Problems with sharing data

vector不是线程安全的。
多个线程同时往一个vector中放入数据时,可能导致数据访问冲突,从而导致数据丢失,甚至崩溃。
举例:

//以下代码无法正常运行
#include <vector>
#include <thread>
#include <iostream>
using namespace std;

vector<int> data;
void put_data(int count) {
	for(int i = 0; i < count; ++i) {
		data.push_back(i);
	}
}

int main() {
	thread t1(put_data, 10000);
	thread t2(put_data, 10000);
	t1.join();
	t2.join();
	cout << "Total number of data:" << data.size() << endl;
	return 0;
}

对于这个问题,我的解法是加锁,加锁的话,估计效率比较低,不知道是否还有更好的解法。

#include <vector>
#include <thread>
#include <mutex>
#include <iostream>
using namespace std;

vector<int> data;
mutex mtx;
void put_data(int count) {
	mtx.lock();
	for(int i = 0; i < count; ++i) {
		data.push_back(i);
	}
	mtx.unlock();
}

int main() {
	thread t1(put_data, 10000);
	thread t2(put_data, 10000);
	t1.join();
	t2.join();
	cout << "Total number of data:" << data.size() << endl;
	return 0;
}

输出:Total number of data:20000

Race conditions

举例:

#include <thread>
#include <iostream>
#include <string>
using namespace std;

string x;
string y;
void swap_data(int count) {
	for(int i = 0; i < count; ++i) {
		string c = x;
		x = y;
		y = c;
	}
}

int main() {
	for(int i = 0; i < 100; ++i) {
		x = "0";
		y = "10000";
		thread t1(swap_data, 100000);
		thread t2(swap_data, 100000);
		t1.join();
		t2.join();
		cout << "x is " << x << ". y is " << y << endl;
	}
	return 0;
}

可能产生的情况:
可能的情况示意图

提问:代码的运行结果是什么?

  1. 以下四种的一种:
    x is 0. y is 10000
    x is 10000. y is 0
    x is 0. y is 0
    x is 10000. y is 10000
  2. 除了以上四种还有其他可能
  3. Crash
  4. 运行结果正常

答案:2

输出
若把上述代码中的x与y改为int类型,执行结果如下:
输出

How to avoid?

解决方案一

保护数据,避免对共享数据同时进行操作
加锁 -> 进行存在数据访问冲突的操作 -> 释放锁
使得存在冲突的操作成为原子操作。

原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何context switch(切换到另一个线程)

解决方案二

重新设计数据结构,并发模式,实现无锁方案。
实现难度较大,但是并发性能会更好。

Mutex lock

使用mutex进行加锁。
定义锁:

#include <mutex>
using namespace std;
mutex mtx;

加锁:

mtx.lock();

释放锁:

mtx.unlock();

举例:

#include <mutex>
using namespace std;

mutex mtx;
void process_data() {
	mtx.lock();
	// do something
	mtx.unlock();
}

使用mutex不要忘记释放锁。

lock_guard封装,在构造函数里加锁,析构函数中释放锁,避免遗忘释放锁的情况。

std::lock_guard<std::mutex> guard(mtx);

潜在的问题:不能及时释放锁,因其只能在对象销毁时释放锁,持有锁的时间可能过长,影响并发性能

更灵活的封装unique_lock,可以不在构造函数中加锁,后续可以重复加锁和释放锁。

//在构造函数中加锁
std::unique_lock<std::mutex> unique(mtx);
//在构造函数中先不加锁
std::unique_lock<std::mutex> unique(mtx, std::defer_lock);

Problems with locking

  • 问题一:并发度降低
    • 加锁的代码无法进行实现并发,将串行执行
    • 性能受到影响

应尽量减少持有锁的时间

  • 问题二:死锁

如何避免死锁?

  • 保证每个线程的加锁顺序一致
  • 一次性加所有的锁
  • 最多加一个锁

recursive lock

同一个线程对同一个锁加两次也会导致死锁。

std::mutex mtx;
mtx.lock();
mtx.lock();

可以使用recursive_mutex mtx来替代mutex,但是其性能受一定影响。

std::recursive_mutex mtx;
mtx.lock();
mtx.lock();
// do something
mtx.unlock();
mtx.unlock();

Concurrent Operation Synchronization

数据处理流水线:
数据处理流水线示意图

问题:Thread2如何知道queue中有数据,需要进行后续处理?

  • 死循环去检查queue中是否有数据,但是CPU很高,浪费CPU资源,对性能影响很大
  • 两次检查之间sleep很短的时间,但是实时性差,在sleep期间没有办法干活,浪费资源,性能也受影响
  • 事件通知

Condition variable

条件变量
申明:

std::condition_variable data_cond;

唤醒一个线程:

data_cond.notify_one();

唤醒所有线程:

data_cond.notify_all();

等待通知,无条件的唤醒:

std::unique_lock unique(mtx);
data_cond.wait(unique);
//等待过程中是不会加锁的,通知后才加锁

有条件的唤醒:

std::unique_lock unique(mtx);
data_cond.wait(unique, []{return i == 1;});

超时等待:

std::unique_lock unique(mtx);
data_cond.wait(unique, std::chrono::seconds(5));
//通常情况下最好都是超时等待

举例:

std::condition_variable data_cond;
std::mutex mtx;
void put_data() {
	// put the data into queue
	data_cond.notify_one();
}
void get_data() {
	std::unique_lock unique(mtx);
	data_cond.wait(unique);
	// get the data from the queue
}

Concurrency Design

Thread-safe data structures

not thread_safe : vector、string、queue、map、…

多线程访问的时候会有数据竞争,导致结果错误甚至崩溃。
为了保证并发没有问题,开发人员都需要对共享访问的对象进行加锁。
对特定数据结构,设计和实现thread-safe的数据结构,比如:共享内存。

Lock-based

类似数据库中基于锁的并发控制

  • 通过加锁解决数据竞争问题
  • 减少不必要的加锁
  • 及时释放锁,尽量缩短持有锁的时间
  • 注意死锁,如果可能避免多个锁
  • 如果可能尽量使用atomic types(原子类型),或者借鉴使用成熟的代码

Lock-free

类似数据库中乐观/多版本并发控制

  • 需要精心设计每一个操作,尽量避免数据竞争
  • 如果数据竞争不可避免,需要有冲突检测机制,并在检测到冲突时,重新做

Lock-free性能通常更好,但是开发难度大很多。

Data sharing

数据分区
在各个线程启动之前,把数据分区,每个线程处理一个分区的数据。
数据分区

Task division

数据处理按任务划分
将数据处理划分为多个子任务,每个任务启动一个/多个线程处理,处理完之后将数据交给下个线程进行下个任务的数据处理,形成数据处理流水线并发。

Factors affecting performance

在性能方面,并发设计需要考虑的因素:

  • 系统资源,包括CPU,内存、网络带宽、磁盘访问速度等
  • 数据竞争频率
  • 缓存数据设计,减少缓存数据的频繁切换
  • 高并发带来的额外负载,比如线程切换、内存消耗等
  • 开发难度

Thread pool

线程池:创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。

作用

  • 控制系统中线程的总数
  • 减少线程启动停止带来的额外系统开销
  • 简化各个模块的线程管理

Testing and Debugging

Concurrency-related bugs

并发可能导致的主要问题:

  • 卡死
    • 死锁
    • 忘了释放锁
    • 并未卡死,只是特别慢
  • 数据竞争导致崩溃
    • 在某种特定的并发执行顺序下,越界访问、访问已释放内存等等
  • 数据竞争导致不确定的错误结果
    • 在某种特定的并发执行顺序下,访问了脏数据,导致结果错误

并发问题的主要特点:

  • 不定期地发生,甚至难以再现
  • 没有规律的错误结果,难以分析原因
  • 不常出现,但是一出现就比较致命

如何解决?

  • 尽可能地收集并发问题发生时的各种信息
    • 日志
    • crash dump
  • 按步骤对问题进行分析
    • 再现问题
    • 分析问题根源
    • 提出解决方案
    • 验证解决方案

再现问题的重要性:

  • 问题再现有利于分析问题
  • 问题再现才能验证解决方案是否有效
  • 不再现问题就分析解决问题,可能导致所有工作都是徒劳的

如何再现问题?

  • 搭建测试环境,再现问题
  • 若无法再见,则需要进行分析或者获得更多的错误信息来调整测试,并最终再现问题
    • 可以通过添加sleep等,制造产生问题的并发执行顺序,从而再现问题
  • 简化测试环境,使其在更短的时间,更简单的环境喜爱再现问题,找到问题根源
  • 如果可能添加单元测试再现问题

分析问题根源:

  • debug分析问题
  • 代码分析
  • 添加日志协助分析
  • 合理修改代码制造再现问题的并发执行顺序
    • 在合适的位置添加sleep

提出解决方案:

  • 加锁
  • 降低并发度,异步操作改为同步操作
  • 等等具体问题具体分析

验证解决方案的重要性:

  • 验证解决方案的有效性,否则不但没有解决问题,反而会制造更多bug

如何验证解决方案?

  • 单元测试
    • 没有解决方案之前,单元测试无法通过
    • 有解决方案之后,单元测试通过
  • 在再现问题的测试环境下反复测试验证,确保问题不再发生

Summary

  1. 并发概念
  2. C++多线程编程
  3. 线程间数据共享与竞争
  4. 线程间的操作同步
  5. 并发设计
  6. 测试与调试分析

推荐书籍:C++ Concurrency in Action

Practice

运行上述代码,可以得知show函数一直不停地在刷新屏幕,并且一直占有CPU,这样效率很低,请修改上述代码, 让show函数仅在input更新x之后再刷新屏幕并将refresh计数加1,确保update count与refresh count一致,CPU不能占有100%;input线程退出后,通知show线程退出,然后整个程序正常退出。

#include <unistd.h>
#include <ncurses.h>
#include <sys/ioctl.h>
#include <string>
#include <thread>
using namespace std;

string x = "Hello world!";
int updateCount = 0, refreshCount = 0;

void input() {
    char chr;
    while(read(fileno(stdin), &chr, 1) == 1) {
        switch (chr){
            case 'q':
                x = "Input thread terminated!";
                updateCount++;
                return;
            case 'h':
                x = "Hello world!";
                updateCount++;
                break;
            case 'w':
                x = "Welcome to C++!";
                updateCount++;
                break;
            default:;
        }
    }
}

void show() {
    while (true) {
        refreshCount++;
        mvwprintw(stdscr, 0, 0, "string value is: %s", x.c_str());
        mvwprintw(stdscr, 1, 0, "update count is: %d", updateCount);
        mvwprintw(stdscr, 2, 0, "refresh count is: %d", refreshCount);
        wrefresh(stdscr);
    }
}

int main() {
    initscr(); noecho(); curs_set(FALSE);
    thread t1(input), t2(show);
    t1.join(); t2.join();
    nocbreak(); endwin();
    return 0;
}

解决方法:对于show()函数进行有条件的唤醒,为其添加condition_variable。

int fomerUpdateCount = updateCount;
//在所有updateCount++前添加代码fomerUpdateCount = updateCount;j记录updateCount的变化

mutex mtx;
std::condition_variable data_cond;
//在show()函数内使用锁和条件变量
std::unique_lock unique(mtx);
data_cond.wait(unique, []{return updateCount > fomerUpdateCount;});
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

随处可见的打字员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值