【C++】并发编程

一、线程创建与管理

1.1 并发

1.1.1 并发与并行

并发:同一时间段内可以交替处理多个操作,强调同一时段内交替发生。

并行:同一时刻内同时处理多个操作,强调同一时刻点同时发生。

1.1.2 硬件并发与任务切换

单片机上的单核处理器支持并发多任务处理,依靠任务切换实现,与多核处理器上的多任务并发处理方式不同。

  • 双核处理器并行执行(硬件并发)对比单核处理器并发执行(任务上下文切换

  • 双核处理器均并发执行(一般任务数远大于处理器核心数,多核并发更常见)

1.1.3 多线程并发与多进程并发

任务

为达到某一目的而进行的一系列操作,在计算机中主要指由软件完成的一个活动;一个任务既可以是一个进程,也可以是一个线程。

举例:读取数据并放入内存,可以通过进程实现,也可以通过线程实现。

进程

系统中并发执行的单位,资源分配的基本单位,也可能作为调度运行的单位,拥有独立的数据空间和代码空间。

举例:用户运行程序,系统即创建一个进程,在为其分配资源后放入就绪队列,当被进程调度程序选中时,为其分配CPU及其他相关资源,该进程开始运行。

线程

执行处理器调度的基本单位。一个进程由一个或多个线程构成,各线程共享相同的代码和全局数据,同时也有各自私有的堆栈。

总结

进程与线程的区别在于:进程有独立的全局数据,线程存在于进程中,故一个进程的所有线程共享该进程的全局数据。由于线程共享同样的系统区域,所以操作系统分配给一个进程的资源对该进程的所有线程都是可用的。

在MAC、Windows NT等采用微内核的操作系统中,进程只作为资源分配的单位,不再是调度运行的单位。在微内核系统中,真正调度运行的基本单位是线程。因此实现并发功能的单位是线程。在Linux系统中,线程只作为特殊的进程存在,二者不做过多区分。

多进程并发与多线程并发的区别主要在于有没有共享数据:多进程间的通信较复杂且代价较大,常见的进程间通信方式有管道、信号、文件、套接字等(C++未提供进程间通信的原生支持);多线程本身共享进程的全局数据。

1.2 如何使用并发

1.2.1 为什么使用并发

程序使用并发的原因有两种:关注点分离和提高性能。

关注点分离

通过将相关的代码放在一起并与无关的代码分开,可以使程序更容易理解和测试,从而减少出错的可能性。使用并发可以分隔不同的功能区域,程序中不同的功能,使用不同的线程去执行。当为了分离关注点而使用多线程时,设计线程的数量的依据,不再是依赖于CPU中的可用内核的数量,而是依据概念上的设计(依据功能的划分)。

提高性能

为了充分发挥多核心处理器的优势,使用并发将单个任务分成几部分且各自并行运行,从而降低总运行时间。根据任务分割方式的不同,又可以将其分为两大类:一类是对同样的数据应用不同的处理算法(任务并行);另一类是用同样的处理算法共同处理数据的几部分(数据并行)。

运行越多的线程,操作系统需要为每个线程分配独立的栈空间,需要越多的上下文切换,这会消耗很多操作系统资源,如果在线程上的任务完成得很快,那么实际执行任务的时间要比启动线程的时间小很多,所以在某些时候,增加一个额外的线程实际上会降低,而非提高应用程序的整体性能,此时收益比不上成本。

1.2.2 在C++中使用并发和多线程

C++11标准中引入了多线程,提供语言级别的多线程原生支持;在此之前需要借助编译器厂商提供的平台相关的扩展多线程API来实现并发编程。

1.3 C++线程创建

函数并发运行时需要确保共享数据的并发访问是安全的。

1.3.1 C++11标准多线程支持库

多线程库 功能
thread 提供线程创建及管理的函数或类接口
mutex 为线程提供获得独占式资源访问能力的互斥算法,保证多个线程对共享资源的同步访问
condition_variable 允许一定量的线程等待(可以定时)被另一线程唤醒,然后再继续执行
future 提供了一些工具来获取异步任务(即在单独的线程中启动的函数)的返回值,并捕捉其所抛出的异常
atomic 为细粒度的原子操作(不能被处理器拆分处理的操作)提供组件,允许无锁并发编程

1.3.2 线程创建示例

线程创建和管理的函数或类主要由< thread >库文件来提供,该库文件的主要操作如下:

操作 效果
thread t 默认构造函数,构造不表示线程的 thread 对象(nonjoinable)
thread t(f, …) 构造新的 std::thread 对象并将它与执行线程关联,f可调用对象将被启动于一个线程中 或 抛出 std::system_error
thread t(rv) 移动构造函数,构造表示曾为 rv 所表示的执行线程的 thread 对象。此调用后 other 不再表示执行线程(nonjoinable)
t.~thread() 销毁*this,若t是joinable则调用std::terminate()
t = rv 移动赋值,将rv状态移动赋值到t,若t是joinable则调用std::terminate()
t.joinable 检查 t 是否有一个关联线程(joinable),若是则返回true。
t.join() 等待关联线程完成工作(joinable),然后令t变成nonjoinable;若t不是joinable则抛出std::system_error
t.detach() 解除t与线程的关联(joinable)并让线程继续运行,然后令t变成nonjoinable;若t不是joinable便抛出std::system_error
t.get_id() 返回std::thread::id(t的唯一标识符)
t.native_handle() 返回依赖于平台的类型native_handle_type,用于不具可移植性的扩展

通过std::thread t(f, args…)创建线程,可以给线程函数传递参数。通过join()函数关联并阻塞线程,等待该线程执行完毕后继续;通过detach()函数解除关联使线程可以与主线程并发执行,但若主线程执行完毕退出后,detach()解除关联的线程即便没有执行完毕,也将自动退出(避免此类情况发生)。

说明:

复制构造函数被删除,thread 不可复制,即不存在两个 std::thread 对象表示同一执行线程。移动或按值复制线程函数的参数,若需要传递引用参数给线程函数,则必须使用 std::ref 或 std::cref 包装。

示例

/*
    文件:thread.cpp
    功能:创建线程,并观察线程的并发执行与阻塞等待

    编译:g++ -Wall -g -std=c++11 -pthread thread.cpp -o thread		
*/
  
#include <iostream>
#include <thread>
#include <chrono>
 
// 可调用对象:函数
void thread_function(int n)
{
   
    // 获取线程ID
    std::thread::id this_id = std::this_thread::get_id();			

    for(int i = 0; i < 5; i++){
       
        std::cout << "Child function thread " << this_id<< " running : " << i+1 << std::endl;
        // 进程睡眠n秒
        std::this_thread::sleep_for(std::chrono::seconds(n));   	
    }
}

// 可调用对象:仿函数
class Thread_functor
{
   
public:
    void operator()(int n)
    {
   
        std::thread::id this_id = std::this_thread::get_id();

        for(int i = 0; i < 5; i++){
   
            std::cout << "Child functor thread " << this_id << " running: " << i+1 << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   
        }
    }	
};
 
int main()
{
   
    // 通过 可调用对象-函数 构造。
    std::thread mythread1(thread_function, 1);
    // 判断是否mythread1是否关联线程
    if(mythread1.joinable())
    {
   
        // 合并线程:阻塞主线程等待mythread1关联的线程完成工作。
        mythread1.join(); 
    }
    
    // 通过 可调用对象-仿函数 构造。
    Thread_functor thread_functor;
    std::thread mythread2(thread_functor, 3);
    if(mythread2.joinable())
    {
   
        // 分离线程:使子线程和主线程并行运行,主线程不再等待子线程。
        mythread2.detach();
    }                         

    // 可调用对象:Lambda表达式
    auto thread_lambda = [](int n){
   
        std::thread::id this_id = std::this_thread::get_id();
        for(int i = 0; i < 5; i++)
        {
   
            std::cout << "Child lambda thread " << this_id << " running: " << i+1 << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   
        }       
    };

    // 通过 可调用对象-Lambda表达式 构造。
    std::thread mythread3(thread_lambda, 4);     
    if(mythread3.joinable())
    {
   
        mythread3.join();
    }

    // 获取主线程ID
    std::thread::id this_id = std::this_thread::get_id();
    for(int i = 0; i < 5; i++){
   
        std::cout << "Main thread " << this_id << " running: " << i+1 << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    
#ifdef WIN32
    system("pause");
#else
    getchar();
#endif
    
    return 0;
}

线程创建的参数是函数对象,包括函数指针、成员函数指针、仿函数及lambda表达式。以上示例分别用三种函数对象创建了三个线程,其中第一个线程mythread1阻塞等待其执行完后继续往下执行,第二个线程mythread2不阻塞等待在后台与后面的第三个线程mythread3并发执行,第三个线程继续阻塞等待其完成后再继续往下执行主线程任务。为了便于观察并发过程,对三个线程均用了睡眠延时this_thread::sleep_for(duration)函数。

补充

针对任何线程(包括主线程),< thread > 声明了命名空间std::this_thread,用以提高线程专属的全局函数。函数声明和效果见下表:

操作 效果
this_thread::get_id() 获取当前线程的ID
this_thread::sleep_for(dur) 将某个线程阻塞dur时间段
this_thread::sleep_until(tp) 将某个线程阻塞到tp时间点
this_thread::yield() 建议释放控制以便重新调度使下一个线程能够执行

二、线程同步之互斥锁

2.1 什么是线程同步

多线程并发: 在同一时间段内交替处理多个操作,线程切换时间片很短(一般为毫秒级),一个时间片多数时候来不及处理完对某一资源的访问。

线程间通信: 一个任务被分割为多个线程并发处理,多个线程可能都要处理某一共享内存的数据,多个线程对同一共享内存数据的访问需要准确有序。

如果多个进程都需要访问相同的共享内存数据,进行读取和写入(数据并发访问或数据竞争),就需要使读写有序(同步化),否则可能会造成数据混乱,无法得到预期的结果。

同步:是指在不同进程之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。

如果用对资源的访问来定义的话,同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。

互斥:是指散布在不同进程之间的若干程序片断,当某个进程运行其中一个程序片段时,其它进程就不能运行它们之中的任一程序片段,只能等到该进程运行完这个程序片段后才可以运行。

如果用对资源的访问来定义的话,互斥某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。

多个线程对共享内存数据访问的竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢占资源以完成自己的任务。C++标准中对数据竞争的定义是:多个线程并发的去修改一个独立对象,数据竞争是未定义行为的起因。

2.2 如何处理数据竞争

数据竞争源于并发修改同一数据结构,则最简单的处理数据竞争的方法就是对该数据结构采用某种保护机制,确保只有进行修改的线程才能看到数据被修改的中间状态,从其他访问线程的角度看,修改不是已经完成就是还未开始。C++标准库提供了很多类似的机制,最基本的就是互斥量,由< mutex >库文件专门支持对共享数据结构的互斥访问。

2.2.1 lock与unlock保护共享资源

Mutex,全名mutual exclusion(互斥体),是个object对象,用来协助采取独占排他方式控制对资源的并发访问。此处的资源可能是个对象,或多个对象的组合。为了获得独占式的资源访问能力,相应的线程必须锁定(lock) mutex,以防止其他线程也锁定mutex,直到该线程解锁(unlock) mutex。

mutex类的主要操作函数见下表

操作 效果
mutex m 默认构造函数,构造未锁定(unlocked)的mutex对象
m.~mutex() 销毁mutex(要求未被锁定)
m.lock() 尝试锁住mutex(阻塞)
m.try_lock() 尝试锁住mutex(锁定成功返回true)
m.try_lock_for(dur) 尝试在时间段dur内锁定(锁定成功返回true)
m.try_lock_until(tp) 尝试在时间点tp之前锁定(锁定成功返回true)
m.unlock() 解除mutex(要求被锁定)
m.native_handle() 返回依赖于平台的类型native_handle_type,用于不具可移植性的扩展

示例:

/*
    文件:mutex_1.cpp
    功能:通过互斥体lock与unlock保护共享全局变量

    编译:g++ -Wall -g -std=c++11 -pthread mutex_1.cpp -o mutex_1
*/

#include <iostream> 
#include <thread>
#include <mutex>
#include <chrono>

std::chrono::milliseconds interval(100);

std::mutex mutex;
// 多线程共享的全局变量,多线程操作,使用mutex保护
int job_shared = 0; 
// 单线程独占的全局变量,无需mutex保护
int job_exclusive = 0;

void job_1()
{
   
    mutex.lock();
    // 持锁等待
    std::this_thread::sleep_for(5 * interval);  
    ++job_shared;
    std::cout << "job_1 shared (" << job_shared << ")\n";
    mutex.unlock();
}

void job_2()
{
   
    while (true) 
    {
       
        // 无限循环,尝试获得锁
        if (mutex.try_lock()) 
        {
        
            // 尝试获得锁成功则修改'job_shared'
            ++job_shared;
            std::cout << "job_2 shared (" << job_shared << ")\n";
            mutex.unlock();
            return;
        }
        else 
        {
         
            // 尝试获得锁失败则修改'job_exclusive'
            ++job_exclusive;
            std::cout << "job_2 exclusive (" << job_exclusive << ")\n";
            std::this_thread::sleep_for(interval);
        }
    }
}

int main()
{
   
    std::thread thread_1(job_1);
    std::thread thread_2(job_2);

    thread_1.join();
    thread_2.join();

#ifdef WIN32
    system("pause");
#endif

    return 0;
}

以上程序创建了两个线程和两个全局变量,其中一个全局变量job_exclusive是排他的,两线程并不共享,不会产生数据竞争,所以不需要锁保护。另一个全局变量job_shared是两线程共享的,会引起数据竞争,因此需要锁保护。线程thread_1持有互斥锁lock的时间较长,线程thread_2为免于空闲等待,使用了尝试锁try_lock,如果获得互斥锁则操作共享变量job_shared,未获得互斥锁则操作排他变量job_exclusive,提高多线程效率。

2.2.2 lock_guard与unique_lock保护共享资源

lock与unlock必须成对合理配合使用,使用不当可能会造成资源被永远锁住,甚至出现死锁(两个线程在释放它们自己的lock之前彼此等待对方的lock)。C++针对lock与unlock引入智能锁lock_guard与unique_lock,使用RAII技术对普通锁进行封装,达到智能管理互斥锁资源释放的效果。

与之类似的new和delete,若使用不当可能会造成内存泄漏等严重问题,为此C++引入了智能指针shared_ptr与unique_ptr。智能指针借用了RAII技术(Resource Acquisition Is Initialization—资源获取即初始化,使用类来封装资源的分配和初始化,在构造函数中完成资源的分配和初始化,在析构函数中完成资源的清理,可以保证正确的初始化和资源释放)对普通指针进行封装,达到智能管理动态内存释放的效果。

lock_guard操作如下

操作 效果
lock_guard lg(m) 为 mutex m 建立一个 lock_guard 并锁定之
lock_guard lg(m, adopt_lock) 为已被锁定的 mutex m 建立一个 lock_guard
lg.~lock_guard() 解锁 mutex m 并销毁 lock_guard

unique_lock操作如下

操作 效果
unique_lock l 默认构造函数,建立一个 lock_guard 但不关联任何 mutex
unique_lock l(m) 为 mutex m 建立一个 lock_guard 并锁定
unique_lock l(m, adopt_lock) 为已锁定的 mutex m 建立一个 lock_guard
unique_lock l(m, defer_lock) 为 mutex m 建立一个 lock_guard 但不锁定
unique_lock l(m, try_lock) 为 mutex m 建立一个 lock_guard 并试图锁定
unique_lock l(m, dur) 为 mutex m 建立一个 lock_guard 并试图在时间段 dur 内锁定
unique_lock l(m, tp) 为 mutex m 建立一个 lock_guard 并试图在时间点 tp 前锁定
unique_lock l(rv) 移动构造函数,将 lock state 从 rv 移到 l(rv 不再关联任何 mutex)
l.~unique_lock() 解锁 mutex (若被锁定)并销毁 lock_guard
unique_lock l = rv 移动赋值,将 lock state 从 rv 移到 l(rv 不再关联任何 mutex)
swap(l1, l2) 交换lock
l1.swap(l2) 交换lock
l.release() 返回指向关联的 mutex 的指针并释放 mutex
l.owns_lock() 若关联的 mutex 被锁定则返回 true
if(l) 检查关联的 mutex 是否被锁定
l.mutex() 返回指向关联的 mutex 的指针
l.lock() 锁住关联的 mutex
l.try_lock() 尝试锁住关联的 mutex (若成功则返回 true)
l.try_lock_for(dur) 尝试在时间段 dur 内锁住关联的 mutex(若成功则返回 true)
l.try_lock_until(tp) 尝试在时间点 tp 之前锁住关联的 mutex(若成功则返回 true)
l.unlock() 解除关联的 mutex

lock_guard与unique_lock描述及策略对比

类模板 描述 策略
std::lock_guard 严格基于作用域(scope-based)的锁管理类模板,构造时是否加锁是可选的(不加锁时假定当前线程已经获得锁的所有权—使用std::adopt_lock策略),析构时自动释放锁,所有权不可转移,对象生存期内不允许手动加锁和释放锁 std::adopt_lock
std::unique_lock 更加灵活的锁管理类模板,构造时是否加锁是可选的,在对象析构时如果持有锁会自动释放锁,所有权可以转移。对象生命期内允许手动加锁和释放锁 std::adopt_lock std::defer_lock std::try_to_lock

对mutex_1.cpp示例进行修改:

  • job_1函数:将普通锁 lock/unlock 替换为智能锁 lock_guard

    void job_1()
    {
         
        // 获取RAII智能锁,离开作用域会自动析构解锁
        std::lock_guard<std::mutex> lockg(mutex);    
        std::this_thread::sleep_for(5 * interval);
        ++job_shared;
        std::cout << "job_1 shared (" << job_shared << ")\n";
    }
    
  • job_2函数:将普通锁 lock/unlock 替换为智能锁 unique_lock( 使用尝试锁策略:std::try_to_lock )

    void job_2()
    {
         
        while (true) 
        {
            
            // 以尝试锁策略创建智能锁
            std::unique_lock<std::mutex> ulock(mutex, std::try_to_lock);		
            if 
  • 4
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值