STL线程开发小结

46 阅读8分钟

学习C++11线程池项目过程中对线程开发的一些知识点进行记录学习

线程 std::thread

  • 创建一个线程
// 默认构造函数,创建一个空的线程执行对象
std::thread();

// 初始化构造函数
template<class T, class... Args>
explict thread(T&& fn, Args&&... args);

// 创建一个简单的线程,提供一个线程函数或者函数对象,同时指定函数所需的参数
void func1()
{
    printf("into func1\n");
}

void func2(int a)
{
    printf("func2:%d\n", a);
}

void func3(int a)
{
    printf("func3:%d\n", a);
}

int main()
{
    std::thread td1 = std::thread(func1);
    td1.join();

    std::thread td2 = std::thread(threadFunc, 1);
    td2.join();
    
    std::thread td3 = std::thread( std::bind(func3, 3) );
    td3.join();
    
    return 0;
}
  • 常用成员函数
    1. get_id(); //获取当前线程ID,返回std::thread:id类型
    2. joinable(); // 判断当前线程是否可以加入等待,返回bool类型
    3. join(); // 使当前线程加入等待,线程函数执行结束后返回
    4. detach(); // 将当前线程从调用线程中分离,使其独立运行,无法被主动kill,只有线程函数执行结束或者主进程结束的时候,才会被回收
  • thread可以绑定普通函数、成员函数、静态函数、仿函数、std::function对象、std::bind的返回值(就是跟绑定对象具有相同函数签名的std::function对象)以及lambda表达式

线程同步的方式

互斥量 std::mutex

  • C++11中最基本的互斥量,对共享资源进行独占式加锁
  • lock(),调用后线程将锁住mutex对象,会出现三种情况 (1). 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用unlock之前,该线程一直拥有该锁。(2). 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(3). 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
  • unlock(),调用后当前线程释放mutex的所有权,即释放锁
  • try_lock(),尝试锁住mutex对象,与lock效果类似,不同的是,如果此时其他线程锁住该互斥量,则try_lock会返回false,不会导致当前线程阻塞。
  • 为避免使用者未在正确的时候释放锁而导致多线程出现异常,C++11提供了lock_guard和unique_lock的使用,采用RAII的方式(初始化即创建,析构函数即释放)实现手动上锁和自动释放释放锁
  • 除了mutex,C++11还提供了递归互斥量std::recursive_mutex和带超时的互斥量std::timed_mutex和 std::recursive_timed_mutex
    • 递归互斥量,允许当前线程递归上锁,解决使用mutex同一线程多次上锁导致死锁的问题
    • 超时互斥量,提供了try_lock_for和try_Lock_until两个成员函数,当超时发生时会自动释放锁,避免长时间死锁
std::mutex mtx;
void test()
{
    {
        std::lock_guard lck (mtx);   // 上锁
        ...
        // 操作共享资源
        ...
    }
    // 离开作用域 mtx自动释放
    
    {
        std::unique_lock<std::mutex> lk(mutex);   // 上锁
        ...
        do something;
        lk.unlock();   // unique_lock和lock_guard用法类似,区别在于unique_lock支持中途主动释放锁
        ...
        lk.lock();     // 也可以在释放后,手动再次上锁,因为这种特性,所以在使用条件变量时采用的是unique_lock,而不是lock_guard
        do something;
        ...
    }
    // 离开作用域,mtx同样因为lk的析构而释放
}

条件变量

  • 互斥量是保证互斥资源被同步访问的保证,但单靠互斥量却实现不了线程的同步调用,即按照可调度方式有序执行的行为。为此C++11提供了此类行为的支持----条件变量
  • 条件变量需要结合std::mutex和std::unique_lock一起使用
  • wait(),有两种重载方式,一种是只有unique_Lock对象,另一种是包含一种等待条件。其工作原理:
    • 当线程调用wait()后将被阻塞,并且会释放当前线程所获取的互斥量,直到其他线程中调用了notify_one或者notify_all,此时当前阻塞线程被唤醒
    • 如果采用第一种重载方式,第一次调用时直接解锁并阻塞,被notify唤醒后,wait重新尝试获取锁,如果得不到则继续阻塞在本行,如果成功获取到锁,则wait()返回,并继续调用后续操作。
    • 如果采用第二种重载方式,则在被唤醒的时候需要判断等待条件是否为true,如果为true则尝试获取锁,成功则返回,失败则继续阻塞;如果等待条件为false,那么就继续进行阻塞
std::mutex mtx;
std::condition_variable cv;
bool b_ready = false;

void threadFuc()
{
    while(1)
    {
        std::unique_lock<std::mutex> lk(mtx);   // 上锁
        // 无等待条件
        cv.wait(lk);  // 解锁,且进入阻塞状态,直达外部调用cv.notify_one或者cv.notify_all
        
        // 有等待条件
        cv.wait(lk, []{return b_ready;}); // 调用notify_one或者notify_all后,还需要b_ready为真,才能重新获取锁
    }
}
  • wait_for(),在wait的基础上添加了一个超时时间段,cv.wait_or(lk, timeouts, []{return b_ready;});,当运行timeouts时长内或者未收到唤醒通知时,都会处于阻塞状态,否则wait_for将返回,其返回方式跟wait是类似的,只是多出了一种返回的可能性,即产生超时
  • wait_until(),用法与wait_for()类似,其区别在于对时间参数的定义不同,这里的时间使用的是一个时间点,在到达某个时间点之前或者未收到唤醒通知,当前线程处于阻塞状态;否则wait_until将返回
  • notify_one(),唤醒一个当前处于阻塞的线程对象,只有一个等待中线程会响应到该通知
  • notify_all(), 唤醒所有处于阻塞的线程对象

信号量

  • counting_semaphore,类似C语言中的sem_t类型,在创建时可以指定信号量的计数,通过P&V操作来控制信号量的计数实现对共享资源的并发访问(C++20中提供支持,须使用vs2019版本以上
  • 当线程想访问共享资源时,此时信号量计数必须是大于0的,成功获得信号量之后,可以访问共享资源,当前信号量计数减1;共享资源操作结束后,须释放当前信号量,此时计数加1
  • 如果信号量计数不大于0,则无法获取该信号量,阻塞挂起,等待信号量计数恢复
  • 信号量计数的加减操作是原子性的,如果信号量初始化为1,则表明同时只能有1个对象可以访问共享资源
std::counting_semaphore sm(1);           //初始化信号量为1
void threadproc()
{
	sm.acquire();     //获取资源 ,相当于P操作,计数-1,类似sem_t::wait()
	...
	do something;
	...
	sm.release(1);    //释放资源,相当于V操作, 计数+1,类似sem_t::post()
}

线程异步操作std::future

  • std::future其效果类似通过一个对象来记录一个事件,并可以在事件完成后由线程主动获取事件的执行结果,简单来说就是可以异步获取某个函数的返回值。在实际应用场景中,线程中执行的函数其他线程是无法感知其返回值的,通常通过传递一个引用类型的参数来获取执行状态,但无法实现同步感知,此时就可以通过std::future来实现。
  • 线程可以周期性的在这个future上等待一小段时间(get()),可以通过wait()来等待异步结果的输入,检查future是否已经就绪(即事件是否已经返回),如果没有,该线程可以先去做另一个任务;一旦future就绪,可以通过future对象来获取执行结果。future只代表一次事件,无法多次获取
  • 如果需要多次获取,则需要使用std::shared_future来达到多次get的目的。原理与shared_ptr类似

如何得到一个std::future对象?

C++11提供了多种可以返回future对象的方式,包括std::async/std::packaged_task/std::promise,这几种方法都是通过绑定一个可调用对象来实现。

std::async

// 需要包含此头文件
#include <future>

int func(int a)
{
    a++;
    ...
    return a;
}

int main()
{
    // async跟thread类似,支持传递参数到函数调用中
    std::future result = std::async(func, 1);
    ...
    do other tings;
    ...
    std::cout << "func return: " << result.get() << std::endl;
}
  • std::async 并不总会开启新的线程来执行任务,你可以指定 std::launch::async 来强制开启新线程
auto f = std::async(std::launch::async, func);

std::packaged_task

  • std::packaged_task实现了对任务的封装,其模板参数是绑定函数的函数签名, 如int test(int a, int b),则其函数签名为<int(int,int)>
  • std::packaged_task的返回类型是void,获取绑定事件的返回值必须先调用get_future()来获取future对象
int func(int a)
{
    a++;
    ...
}

int main()
{
    std::packaged_task<int(int)> t(func);
    t();   // 这样使用类似就是一个仿函数,get()是不会阻塞的
    std::cout << "func return: " << t.get_futrue().get() << std::endl;
    
    //将任务抛到线程中执行,在调用get()就会阻塞,直到t执行结束
    std::thread t { std::move(t), 5 };
    std::cout << "func return: " << t.get_futrue().get() << std::endl;
}

std::promise

-promise提供了一个设置值的方式,可以通过与之关联的future对象进行读取,默认的future就绪条件是绑定函数执行完毕,而promise额外提供了可以主动触发future就绪的方式。

void test(std::promise<int> && p)
{
    p.set_value(8);    // 执行线程对promise对象进行设置,设置成功
}

int main()
{
    std::promise<int> promise;    // 创建一个promise对象
    std::future<int> result = promise.get_future();    // 得到其future对象
    
    std::thread t(test, std::ref(promise));    // 将promise对象传递到执行线程中
    ...
    do other things;
    ...
    std::cout << result.get() << std::endl;    // p.set_value(8)执行后,才可以通过get()读取到当前值
    t.join();
}

总结

  • 可以看出promis跟packaged_task的future对象创建时机有所不同;promise在将自己传递给线程之前,线程外是已经可以持有future;但是promise将我们所关心的异步数据在自行调用set_value后才可以通过get返回,其抽象程度相对较低
  • std::async用于创建异步任务,使用最简单,其抽象程度最高
  • std::packaged_task则将一个可调用对象(包括函数、函数对象、lambda表达式、std::bind表达式、std::function对象)进行包装,以便该任务能被异步调用(即在其他线程中调用)
  • 二者均可通过std::future对象返回执行结果。二者使用的一个主要差别是:std::packaged_task需要等待执行结果返回,而std::async不必

可变模板参数

  • C++11最强大的特性之一
  • 模板语法
template <class... T>
void f(T... args);
  • 上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数。我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数。
  • 定义一个可变模板参数函数
template<class... T>
void func(T... args)
{
    std::cout << sizeof...(args) << std::endl;   // 打印参数包中的参数个数
}

int main()
{
    func();    // 0
    func(1, 2);   // 2
    func(1, 2, "abc"); //3
    return 0;
}

如何展开参数包?

  1. 递归函数 + 递归终止函数
/* 递归方式展开需要提供两个函数
1.递归展开
2.递归终止
*/

// 递归终止
void print()
{
    // 参数包为空时,调用当前函数,递归开始返回,即递归终止
    std::cout << "over" << std::endl;
}

// 递归终止也可以这么写
template <class T>
print(T head)
{
    std::cout << "last Param:" << head << std::endl;
}

// 递归展开
template<class T, class... args>
void print(T head, T... args)
{
    std::cout << "curParam:" << head << std::endl;    // 打印参数包中的最左侧参数
    print(args...);  // 递归剩余参数包
}

int main()
{
    print(1,2,3, "ok");
    return 0;
}
  1. ... + 初始化列表 + 逗号表达式
    • 逗号表达式就是逗号运算符,优先级别最低,将两个及以上的子表达式连起来,从左往右逐个计算,整个表达式的值为最后一个表达式的值
    • ...可以将参数包展开
  • ...结合初始化列表的使用方法
template <class... T>
void test(T... args)
{
    auto tmp[] = {args...};
    for(auto &v:tmp) // C++11范围遍历
    {
        std::cout << v << std::endl;
    }
}

int main()
{
    // 初始化列表要求参数类型是一致的,这里采用的是相同的参数类型组成的参数包
    test(1,2,3);   // 这里auto tmp = {1,2,3}; 会将args参数包展开到初始化列表中
    test("a","b", "c");  // auto tmp = {a, b, c};
}

  • 结合逗号表达式的使用方法
template <class T>
void printArg(T t)
{
    std::cout << t <<std::endl;
}

template <class... Args>
void expand(Args... args)
{
    // 如果参数类型不一致,这里就需要借助逗号表达式,对初始化列表的值进行处理
    int arr[] = {(printArg(args), 0)...}
}

int main()
{
    expand(1,2,3,4);
    // 此时展开为
    int arr[]={(printArg(1), 0), (printArg(2), 0),(printArg(3), 0),(printArg(4), 0)};
    // 根据逗号表达式的计算方式,此时arr是一个被0填充的数组
    
    return 0;
}