异步线程和高级线程池管理

#include <iostream>
#include <thread>
#include <mutex>
#include<future>
#include<Windows.h>
using namespace std;
double t1(const double a, const double b)
{
 double c = a + b;
 Sleep(3000);//假设t1函数是个复杂的计算过程,需要消耗3秒
 return c;
}

int main() 
{
 double a = 2.3;
 double b = 6.7;
 future<double> fu = async(t1, a, b);//创建异步线程线程,并将线程的执行结果用fu占位;
 cout << "正在进行计算" << endl;
 cout << "计算结果马上就准备好,请您耐心等待" << endl;
 cout << "计算结果:" << fu.get() << endl;//阻塞主线程,直至异步线程return
        //cout << "计算结果:" << fu.get() << endl;//取消该语句注释后运行会报错,因为future对象的get()方法只能调用一次。
 return 0;
}

async与future:
std::async是一个函数模板,用来启动一个异步任务,它返回一个std::future类模板对象,future对象起到了占位的作用(记住这点就可以了),占位是什么意思?就是说该变量现在无值,但将来会有值(好比你挤公交瞧见空了个座位,刚准备坐下去就被旁边的小伙给拦住了:“这个座位有人了”,你反驳道:”这不是空着吗?“,小伙:”等会人就来了“),刚实例化的future是没有储存值的,但在调用std::future对象的get()成员函数时,主线程会被阻塞直到异步线程执行结束,并把返回结果传递给std::future,即通过FutureObject.get()获取函数返回值。

相当于你去办政府办业务(主线程),把资料交给了前台,前台安排了人员去给你办理(std::async创建子线程),前台给了你一个单据(std::future对象),说你的业务正在给你办(子线程正在运行),等段时间你再过来凭这个单据取结果。过了段时间,你去前台取结果(调用get()),但是结果还没出来(子线程还没return),你就在前台等着(阻塞),直到你拿到结果(子线程return),你才离开(不再阻塞)。
shared_future
std::future与std::shard_future的用途都是为了占位,但是两者有些许差别。std::future的get()成员函数是转移数据所有权;std::shared_future的get()成员函数是复制数据。 因此: future对象的get()只能调用一次;无法实现多个线程等待同一个异步线程,一旦其中一个线程获取了异步线程的返回值,其他线程就无法再次获取。 std::shared_future对象的get()可以调用多次;可以实现多个线程等待同一个异步线程,每个线程都可以获取异步线程的返回值。


1.原子类型atomic<>


原子操作指“不可分割的操作”,也就是说这种操作状态要么是完成的,要么是没完成的,不存在“操作完成了一半”这种状况。互斥量的加锁一般是针对一个代码段,而原子操作针对的一般都是一个变量(操作变量时加锁防止他人干扰)。 std::atomic<>是一个模板类,使用该模板类实例化的对象,提供了一些保证原子性的成员函数来实现共享数据的常用操作。

可以这样理解: 在以前,定义了一个共享的变量(int i=0),多个线程会用到这个变量,那么每次操作这个变量时,都需要lock加锁,操作完毕unlock解锁,以保证线程之间不会冲突;但是这样每次加锁解锁、加锁解锁就显得很麻烦,那怎么办呢? 现在,实例化了一个类对象(std::atomic<int> I=0)来代替以前的那个变量(这里的对象I你就把它看作一个变量,看作对象反而难以理解了),每次操作这个对象时,就不用lock与unlock,这个对象自身就具有原子性(相当于加锁解锁操作不用你写代码实现,能自动加锁解锁了),以保证线程之间不会冲突。

提到std::atomic<>,你脑海里就想到一点就可以了:std::atomic<>用来定义一个自动加锁解锁的共享变量(“定义”“变量”用词在这里是不准确的,但是更加贴切它的实际功能),供多个线程访问而不发生冲突。

//原子类型的简单使用

#include<iostream>
#include<thread>
#include<mutex>
#include<queue>
#include<condition_variable>


using namespace std;

//缓冲区存储的数据类型 
struct CacheData
{
    //商品id 
    int id;
    //商品属性 
    string data;
};

queue<CacheData> Q;
//缓冲区最大空间 
const int MAX_CACHEDATA_LENGTH = 10;
//互斥量,生产者之间,消费者之间,生产者和消费者之间,同时都只能一个线程访问缓冲区 
mutex m;
condition_variable condConsumer;
condition_variable condProducer;
//全局商品id 
int ID = 1;

//消费者动作 
void ConsumerActor()
{
    unique_lock<mutex> lockerConsumer(m);
    cout << "[" << this_thread::get_id() << "] 获取了锁" << endl; 
    while (Q.empty())
    {
        cout <<  "因为队列为空,所以消费者Sleep" << endl; 
        cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl;
        //队列空, 消费者停止,等待生产者唤醒 
        condConsumer.wait(lockerConsumer);
        cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl; 
    }
    cout << "[" << this_thread::get_id() << "] "; 
    CacheData temp = Q.front();
    cout << "- ID:" << temp.id << " Data:" << temp.data << endl;
    Q.pop(); 
    condProducer.notify_one();
    cout << "[" << this_thread::get_id() << "] 释放了锁" << endl; 
}

//生产者动作 
void ProducerActor()
{
    unique_lock<mutex> lockerProducer(m);
    cout << "[" << this_thread::get_id() << "] 获取了锁" << endl; 
    while (Q.size() > MAX_CACHEDATA_LENGTH)
    {
        cout <<  "因为队列为满,所以生产者Sleep" << endl; 
        cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl; 
        //对列慢,生产者停止,等待消费者唤醒 
        condProducer.wait(lockerProducer);
        cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl; 
    }
    cout << "[" << this_thread::get_id() << "] "; 
    CacheData temp;
    temp.id = ID++;
    temp.data = "*****";
    cout << "+ ID:" << temp.id << " Data:" << temp.data << endl; 
    Q.push(temp);
    condConsumer.notify_one();
    cout << "[" << this_thread::get_id() << "] 释放了锁" << endl; 
}

//消费者 
void ConsumerTask()
{
    while(1)
    {
        ConsumerActor();
    }    
}

//生产者 
void ProducerTask()
{
    while(1)
    {
        ProducerActor();
    }    
}

//管理线程的函数 
void Dispatch(int ConsumerNum, int ProducerNum)
{
    vector<thread> thsC;
    for (int i = 0; i < ConsumerNum; ++i)
    {
        thsC.push_back(thread(ConsumerTask));
    }
    
    vector<thread> thsP;
    for (int j = 0; j < ProducerNum; ++j)
    {
        thsP.push_back(thread(ProducerTask));
    }
    
    for (int i = 0; i < ConsumerNum; ++i)
    {
        if (thsC[i].joinable())
        {
            thsC[i].join();
        }
    }
    
    for (int j = 0; j < ProducerNum; ++j)
    {
        if (thsP[j].joinable())
        {
            thsP[j].join();
        }
    }
}

int main()
{
    //一个消费者线程,5个生产者线程,则生产者经常要等待消费者 
    Dispatch(1,5);
    return 0; 
}


std::atomic<bool> b(true);
b=false;
std::atomic<>对象提供了常见的原子操作(通过调用成员函数实现对数据的原子操作): store是原子写操作,load是原子读操作。exchange是于两个数值进行交换的原子操作。 即使使用了std::atomic<>,也要注意执行的操作是否支持原子性,也就是说,你不要觉得用的是具有原子性的变量(准确说是对象)就可以为所欲为了,你对它进行的运算不支持原子性的话,也不能实现其原子效果。一般针对++,–,+=,-=,&=,|=,^=是支持的,这些原子操作是通过在std::atomic<>对象内部进行运算符重载实现的。

2.代码实例

2.1 生产者消费者问题


生产者-消费者模型是经典的多线程并发协作模型。

生产者用于生产数据,生产一个就往共享数据区存一个,如果共享数据区已满的话,生产者就暂停生产,等待消费者的通知后再启动。

消费者用于消费数据,一个一个的从共享数据区取,如果共享数据区为空的话,消费者就暂停取数据,等待生产者的通知后再启动。

生产者与消费者不能直接交互,它们之间所共享的数据使用队列结构来实现;

如下代码着重强调的是简单易懂:

不采用线程池时:

创建线程 -> 由该线程执行任务 -> 任务执行完毕后销毁线程。即使需要使用到大量线程,每个线程都要按照这个流程来创建、执行与销毁。

虽然创建与销毁线程消耗的时间 远小于 线程执行的时间,但是对于需要频繁创建大量线程的任务,创建与销毁线程 所占用的时间与CPU资源也会有很大占比。

为了减少创建与销毁线程所带来的时间消耗与资源消耗,因此采用线程池的策略:

程序启动后,预先创建一定数量的线程放入空闲队列中,这些线程都是处于阻塞状态,基本不消耗CPU,只占用较小的内存空间。

接收到任务后,任务被挂在任务队列,线程池选择一个空闲线程来执行此任务。

任务执行完毕后,不销毁线程,线程继续保持在池中等待下一次的任务。

线程池所解决的问题:

(1) 需要频繁创建与销毁大量线程的情况下,由于线程预先就创建好了,接到任务就能马上从线程池中调用线程来处理任务,减少了创建与销毁线程带来的时间开销和CPU资源占用。

(2) 需要并发的任务很多时候,无法为每个任务指定一个线程(线程不够分),使用线程池可以将提交的任务挂在任务队列上,等到池中有空闲线程时就可以为该任务指定线程。

3.1 线程池

很多公司里,雇员通常会在办公室度过他们的办公时光(偶尔也会外出访问客户或供应商),或是参加贸易展会。虽然外出可能很有必要,并且可能需要很多人一起去,不过对于一些特别的雇员来说,一趟可能就是几个月,甚至是几年。公司要给每个雇员都配一辆车,这基本上是不可能的,不过公司可以提供一些共用车辆;这样就会有一定数量车,来让所有雇员使用。当一个员工要去异地旅游时,那么他就可以从共用车辆中预定一辆,并在返回公司的时候将车交还。如果某天没有闲置的共用车辆,雇员就得不延后其旅程了。

线程池就是类似的一种方式,在大多数系统中,将每个任务指定给某个线程是不切实际的,不过可以利用现有的并发性,进行并发执行。线程池就提供了这样的功能,提交到线程池中的任务将并发执行,提交的任务将会挂在任务队列上。队列中的每一个任务都会被池中的工作线程所获取,当任务执行完成后,再回到线程池中获取下一个任务。

创建一个线程池时,会遇到几个关键性的设计问题,比如:可使用的线程数量,高效的任务分配方式,以及是否需要等待一个任务完成。

在本节从最简单的线程池开始吧!

3.1.1 最简单的线程池

作为最简单的线程池,其拥有固定数量的工作线程(通常工作线程数量与std

:hardware_concurrency()相同)。当工作需要完成时,可以调用函数将任务挂在任务队列中。每个工作线程都会从任务队列上获取任务,然后执行这个任务,执行完成后再回来获取新的任务。在最简单的线程池中,线程就不需要等待其他线程完成对应任务了。如果需要等待,就需要对同步进行管理。

下面清单中的代码就展示了一个最简单的线程池实现。

 简单的线程池

class thread_pool
{
std::atomic_bool done;
thread_safe_queue<std::function<void()> > work_queue; // 1
std::vector<std::thread> threads; // 2
join_threads joiner; // 3

void worker_thread()
{
while(!done) // 4
{
std::function<void()> task;
if(work_queue.try_pop(task)) // 5
{
task(); // 6
}
else
{
std::this_thread::yield(); // 7
}
}
}

public:
thread_pool():
done(false),joiner(threads)
{
unsigned const thread_count=std::thread::hardware_concurrency(); // 8

try
{
for(unsigned i=0;i<thread_count;++i)
{
threads.push_back(
std::thread(&thread_pool::worker_thread,this)); // 9
}
}
catch(...)
{
done=true; // 10
throw;
}
}

~thread_pool()
{
done=true; // 11
}

template<typename FunctionType>
void submit(FunctionType f)
{
work_queue.push(std::function<void()>(f)); // 12
}
};

实现中有一组工作线程②,并且使用了一个线程安全队列①来管理任务队列。这种情况下,用户不用等待任务,并且任务不需要返回任何值,所以可以使用std::function<void()>对任务进行封装。submit()函数会将函数或可调用对象包装成一个std::function<void()>实例,并将其推入队列中⑫。

线程始于构造函数:使用std

:hardware_concurrency()来获取硬件支持多少个并发线程⑧,这些线程会在worker_thread()成员函数中执行⑨。

当有异常抛出时,线程启动就会失败,所以需要保证任何已启动的线程都能停止,并且能在这种情况下清理干净。当有异常抛出时,通过使用try-catch来设置done标志⑩,还有join_threads类的实例③用来汇聚所有线程。当然也需要析构函数:仅设置done标志⑪,并且join_threads确保所有线程在线程池销毁前全部执行完成。注意成员声明的顺序很重要:done标志和worker_queue必须在threads数组之前声明,而数据必须在joiner前声明。这就能确保成员能以正确的顺序销毁;比如,所有线程都停止运行时,队列就可以安全的销毁了。

worker_thread函数很简单:从任务队列上获取任务⑤,以及同时执行这些任务⑥,执行一个循环直到done标志被设置④。如果任务队列上没有任务,函数会调用std

:yield()让线程休息⑦,并且给予其他线程向任务队列上推送任务的机会。

一些简单的情况,这样线程池就足以满足要求,特别是任务没有返回值,或需要执行一些阻塞操作的时候。不过,在很多情况下,这样简单的线程池完全不够用,其他情况使用这样简单的线程池可能会出现问题,比如:死锁。同样,在简单例子中,使用std::async能提供更好的功能。

在本章中,我们将了解一下更加复杂的线程池实现,通过添加特性满足用户需求,或减少问题的发生几率。

首先,从已经提交的任务开始说起。

3.1.2 等待提交到线程池中的任务

线程间的任务划分完成后,代码会显式生成新线程,主线程通常就是等待新线程在返回调用之前结束,确保所有任务都完成。使用线程池,就需要等待任务提交到线程池中,而非直接提交给单个线程。这与基于std::async的方法类似,使用简单线程池,使用工具:条件变量和future。虽然,会增加代码的复杂度,不过,要比直接对任务进行等待的方式好很多。

通过增加线程池的复杂度,可以直接等待任务完成。使用submit()函数返回一个对任务描述的句柄,用来等待任务的完成。任务句柄会用条件变量或future进行包装,这样能使用线程池来简化代码。

一种特殊的情况是,执行任务的线程需要返回一个结果到主线程上进行处理。你已经在本书中看到多个这样的例子,比如:parallel_accumulate()。这种情况下,需要用future对最终的结果进行转移。对简单线程池的修改,通过修改就能等待任务完成,以及在工作线程完成后,返回一个结果到等待线程中去,不过std::packaged_task<>实例是不可拷贝的,仅是可移动的,所以不能再使用std::function<>来实现任务队列,因为std::function<>需要存储可复制构造的函数对象。包装一个自定义函数,用来处理只可移动的类型。这就是一个带有函数操作符的类型擦除类。只需要处理那些没有函数和无返回的函数,所以这是一个简单的虚函数调用。

可等待任务的线程池

class function_wrapper
{
struct impl_base {
virtual void call()=0;
virtual ~impl_base() {}
};

std::unique_ptr<impl_base> impl;
template<typename F>
struct impl_type: impl_base
{
F f;
impl_type(F&& f_): f(std::move(f_)) {}
void call() { f(); }
};
public:
template<typename F>
function_wrapper(F&& f):
impl(new impl_type<F>(std::move(f)))
{}

void operator()() { impl->call(); }

function_wrapper() = default;

function_wrapper(function_wrapper&& other):
impl(std::move(other.impl))
{}

function_wrapper& operator=(function_wrapper&& other)
{
impl=std::move(other.impl);
return *this;
}

function_wrapper(const function_wrapper&)=delete;
function_wrapper(function_wrapper&)=delete;
function_wrapper& operator=(const function_wrapper&)=delete;
};

class thread_pool
{
thread_safe_queue<function_wrapper> work_queue; // 使用function_wrapper,而非使用std::function

void worker_thread()
{
while(!done)
{
function_wrapper task;
if(work_queue.try_pop(task))
{
task();
}
else
{
std::this_thread::yield();
}
}
}
public:
template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type> // 1
submit(FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type
result_type; // 2

std::packaged_task<result_type()> task(std::move(f)); // 3
std::future<result_type> res(task.get_future()); // 4
work_queue.push(std::move(task)); // 5
return res; // 6
}
// 休息一下
};

首先,修改的是submit()函数①返回一个std::future<>保存任务的返回值,并且允许调用者等待任务完全结束。因为需要知道提供函数f的返回类型,所以使用std::result_of<>std::result_of<FunctionType()>::type是FunctionType类型的引用实例(如,f),并且没有参数。同样,函数中可以对result_type typedef②使用std::result_of<>

然后,将f包装入std::packaged_task<result_type()>③,因为f是一个无参数的函数或是可调用对象,能够返回result_type类型的实例。向任务队列推送任务⑤和返回future⑥前,就可以从std::packaged_task<>中获取future④。注意,要将任务推送到任务队列中时,只能使用std::move(),因为std::packaged_task<>是不可拷贝的。为了对任务进行处理,队列里面存的就是function_wrapper对象,而非std::function<void()>对象。

现在线程池允许等待任务,并且返回任务后的结果。下面的清单就展示了,如何让parallel_accumuate函数使用线程池。

 parallel_accumulate使用一个可等待任务的线程池

template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last);

if(!length)
return init;

unsigned long const block_size=25;
unsigned long const num_blocks=(length+block_size-1)/block_size; // 1

std::vector<std::future<T> > futures(num_blocks-1);
thread_pool pool;

Iterator block_start=first;
for(unsigned long i=0;i<(num_blocks-1);++i)
{
Iterator block_end=block_start;
std::advance(block_end,block_size);
futures[i]=pool.submit(accumulate_block<Iterator,T>()); // 2
block_start=block_end;
}
T last_result=accumulate_block<Iterator,T>()(block_start,last);
T result=init;
for(unsigned long i=0;i<(num_blocks-1);++i)
{
result+=futures[i].get();
}
result += last_result;
return result;
}

有几个点需要注意一下。首先,工作量是依据使用的块数(num_blocks①),而不是线程的数量。为了利用线程池的最大化可扩展性,需要将工作块划分为最小工作块。当线程池中线程不多时,每个线程将会处理多个工作块,不过随着硬件可用线程数量的增长,会有越来越多的工作块并发执行。

当你选择“因为能并发执行,最小工作块值的一试”时,就需要谨慎了。向线程池提交一个任务有一定的开销;让工作线程执行这个任务,并且将返回值保存在std::future<>中,对于太小的任务,这样的开销不划算。如果任务块太小,使用线程池的速度可能都不及单线程。

假设,任务块的大小合理,就不用为这些事而担心:打包任务、获取future或存储之后要汇入的std::thread对象;使用线程池的时候,这些都需要注意。之后,就是调用submit()来提交任务②。

线程池也需要注意异常安全。任何异常都会通过submit()返回给future,并在获取future的结果时,抛出异常。如果函数因为异常退出,线程池的析构函数会丢掉那些没有完成的任务,等待线程池中的工作线程完成工作。

在简单的例子中,这个线程池工作的还算不错,因为这里的任务都是相互独立的。不过,当任务队列中的任务有依赖关系时,这个线程池就不能胜任了。

3.1.3 等待依赖任务

快速排序算法为例,原理很简单:数据与中轴数据项比较,在中轴项两侧分为大于和小于的两个序列,然后再对这两组序列进行排序。这两组序列会递归排序,最后会整合成一个全排序序列。要将这个算法写成并发模式,需要保证递归调用能够使用硬件的并发能力。

第一次接触这个例子,我们使用std::async来执行每一层的调用,让标准库来选择,是在新线程上执行这个任务,还是当对应get()调用时,进行同步执行。运行起来很不错,因为每一个任务都在其自己的线程上执行,或当需要的时候进行调用。

使用了一个固定线程数量(根据硬件可用并发线程数)的结构体。在这样的情况下,使用了栈来挂起要排序的数据块。当每个线程在为一个数据块排序前,会向数据栈上添加一组要排序的数据,然后对当前数据块排序结束后,接着对另一块进行排序。这里,等待其他线程完成排序,可能会造成死锁,因为这会消耗有限的线程。有一种情况很可能会出现,就是所有线程都在等某一个数据块被排序,不过没有线程在做排序。通过拉取栈上数据块的线程,对数据块进行排序,来解决这个问题;因为,已处理的指定数据块,就是其他线程都在等待排序的数据块。

如果只用简单的线程池进行替换,例如:第4章替换std::async的线程池。只有固定数量的线程,因为线程池中没有空闲的线程,线程会等待没有被安排的任务。因此,当等待某个数据块完成时,去处理未完成的数据块。如果使用线程池来管理任务列表和相关线程——使用线程池的主要原因——就不用再去访问任务列表了。可以对线程池做一些改动,自动完成这些事情。

最简单的方法就是在thread_pool中添加一个新函数,来执行任务队列上的任务,并对线程池进行管理。高级线程池的实现可能会在等待函数中添加逻辑,或等待其他函数来处理这个任务,优先的任务会让其他的任务进行等待。下面清单中的实现,就展示了一个新run_pending_task()函数,对于快速排序的修改将会在清单9.5中展示。

run_pending_task()函数实现

void thread_pool::run_pending_task()
{
  function_wrapper task;
  if(work_queue.try_pop(task))
  {
    task();
  }
  else
  {
    std::this_thread::yield();
  }
}

run_pending_task()的实现去掉了在worker_thread()函数的主循环。函数任务队列中有任务的时候,执行任务;要是没有的话,就会让操作系统对线程进行重新分配。

下面快速排序算法的实现简单许多,因为所有线程管理逻辑都被移入到线程池。

基于线程池的快速排序实现

template<typename T>
struct sorter  // 1
{
  thread_pool pool;  // 2
  std::list<T> do_sort(std::list<T>& chunk_data)
  {
    if(chunk_data.empty())
    {
      return chunk_data;
    }
    std::list<T> result;
    result.splice(result.begin(),chunk_data,chunk_data.begin());
    T const& partition_val=*result.begin();
    typename std::list<T>::iterator divide_point=
      std::partition(chunk_data.begin(),chunk_data.end(),
                     [&](T const& val){return val<partition_val;});
    std::list<T> new_lower_chunk;
    new_lower_chunk.splice(new_lower_chunk.end(),
                           chunk_data,chunk_data.begin(),
                           divide_point);
    std::future<std::list<T> > new_lower=  // 3
      pool.submit(std::bind(&sorter::do_sort,this,
                            std::move(new_lower_chunk)));
    std::list<T> new_higher(do_sort(chunk_data));
    result.splice(result.end(),new_higher);
    while(!new_lower.wait_for(std::chrono::seconds(0)) ==
      std::future_status::timeout)
    {
      pool.run_pending_task();  // 4
    }
    result.splice(result.begin(),new_lower.get());
    return result;
  }
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)
{
  if(input.empty())
  {
    return input;
  }
  sorter<T> s;
  return s.do_sort(input);
}

这里将实际工作放在sorter类模板的do_sort()成员函数中执行①,即使例子中仅对thread_pool实例进行包装②。

线程和任务管理,在线程等待的时候,就会少向线程池中提交一个任务③,并且执行任务队列上未完成的任务④。需要显式的管理线程和栈上要排序的数据块。当有任务提交到线程池中,可以使用std::bind()绑定this指针到do_sort()上,绑定是为了让数据块进行排序。这种情况下,需要对new_lower_chunk使用std::move()将其传入函数,数据移动要比拷贝的方式开销少。

虽然,使用等待其他任务的方式,解决了死锁问题,这个线程池距离理想的线程池很远。

首先,每次对submit()的调用和对run_pending_task()的调用,访问的都是同一个队列。在第8章中,当多线程去修改一组数据,就会对性能有所影响,所以需要解决这个问题。

3.1.4 避免队列中的任务竞争

线程每次调用线程池的submit()函数,都会推送一个任务到工作队列中。就像工作线程为了执行任务,从任务队列中获取任务一样。这意味着随着处理器的增加,在任务队列上就会有很多的竞争,这会让性能下降。使用无锁队列会让任务没有明显的等待,但是乒乓缓存会消耗大量的时间。

为了避免乒乓缓存,每个线程建立独立的任务队列。这样,每个线程就会将新任务放在自己的任务队列上,并且当线程上的任务队列没有任务时,去全局的任务列表中取任务。下面列表中的实现,使用了一个thread_local变量,来保证每个线程都拥有自己的任务列表(如全局列表那样)。

线程池——线程具有本地任务队列

template<typename T>
struct sorter // 1
{
thread_pool pool; // 2

std::list<T> do_sort(std::list<T>& chunk_data)
{
if(chunk_data.empty())
{
return chunk_data;
}

std::list<T> result;
result.splice(result.begin(),chunk_data,chunk_data.begin());
T const& partition_val=*result.begin();

typename std::list<T>::iterator divide_point=
std::partition(chunk_data.begin(),chunk_data.end(),
[&](T const& val){return val<partition_val;});

std::list<T> new_lower_chunk;
new_lower_chunk.splice(new_lower_chunk.end(),
chunk_data,chunk_data.begin(),
divide_point);

std::future<std::list<T> > new_lower= // 3
pool.submit(std::bind(&sorter::do_sort,this,
std::move(new_lower_chunk)));

std::list<T> new_higher(do_sort(chunk_data));

result.splice(result.end(),new_higher);
while(!new_lower.wait_for(std::chrono::seconds(0)) ==
std::future_status::timeout)
{
pool.run_pending_task(); // 4
}

result.splice(result.begin(),new_lower.get());
return result;
}
};

template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)
{
if(input.empty())
{
return input;
}
sorter<T> s;

return s.do_sort(input);
}

 

class thread_pool
{
thread_safe_queue<function_wrapper> pool_work_queue;

typedef std::queue<function_wrapper> local_queue_type; // 1
static thread_local std::unique_ptr<local_queue_type>
local_work_queue; // 2

void worker_thread()
{
local_work_queue.reset(new local_queue_type); // 3
while(!done)
{
run_pending_task();
}
}

public:
template<typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type>
submit(FunctionType f)
{
typedef typename std::result_of<FunctionType()>::type result_type;

std::packaged_task<result_type()> task(f);
std::future<result_type> res(task.get_future());
if(local_work_queue) // 4
{
local_work_queue->push(std::move(task));
}
else
{
pool_work_queue.push(std::move(task)); // 5
}
return res;
}

void run_pending_task()
{
function_wrapper task;
if(local_work_queue && !local_work_queue->empty()) // 6
{
task=std::move(local_work_queue->front());
local_work_queue->pop();
task();
}
else if(pool_work_queue.try_pop(task)) // 7
{
task();
}
else
{
std::this_thread::yield();
}
}
// rest as before
};

因为不希望非线程池中的线程也拥有一个任务队列,使用std::unique_ptr<>指向线程本地的工作队列②;这个指针在worker_thread()中进行初始化③。std:unique_ptr<>的析构函数会保证在线程退出的时候,工作队列被销毁。

submit()会检查当前线程是否具有一个工作队列④。如果有,就是线程池中的线程,可以将任务放入线程的本地队列中;否者,就像之前一样将这个任务放在线程池中的全局队列中⑤。

run_pending_task()⑥中的检查和之前类似,只是要对是否存在本地任务队列进行检查。如果存在,就会从队列中的第一个任务开始处理;注意本地任务队列可以是一个普通的std::queue<>①,因为这个队列只能被一个线程所访问,就不存在竞争。如果本地线程上没有任务,就会从全局工作列表上获取任务⑦。

这样就能有效避免竞争,不过当任务分配不均时,造成的结果就是:某个线程本地队列中有很多任务的同时,其他线程无所事事。例如:举一个快速排序的例子,只有一开始的数据块能在线程池上被处理,因为剩余部分会放在工作线程的本地队列上进行处理,这样的使用方式也违背使用线程池的初衷。

幸好,这个问题是有解:本地工作队列和全局工作队列上没有任务时,可从别的线程队列中窃取任务。

3.1.5 窃取任务

为了让没有任务的线程能从其他线程的任务队列中获取任务,就需要本地任务列表可以进行访问,这样才能让run_pending_tasks()窃取任务。需要每个线程在线程池队列上进行注册,或由线程池指定一个线程。同样,还需要保证数据队列中的任务适当的被同步和保护,这样队列的不变量就不会被破坏。

实现一个无锁队列,让其拥有线程在其他线程窃取任务的时候,能够推送和弹出一个任务是可能的;不过,这个队列的实现就超出了本书的讨论范围。为了证明这种方法的可行性,将使用一个互斥量来保护队列中的数据。我们希望任务窃取是一个不常见的现象,这样就会减少对互斥量的竞争,并且使得简单队列的开销最小。下面,实现了一个简单的基于锁的任务窃取队列。

基于锁的任务窃取队列

class work_stealing_queue
{
private:
typedef function_wrapper data_type;
std::deque<data_type> the_queue; // 1
mutable std::mutex the_mutex;

public:
work_stealing_queue()
{}

work_stealing_queue(const work_stealing_queue& other)=delete;
work_stealing_queue& operator=(
const work_stealing_queue& other)=delete;

void push(data_type data) // 2
{
std::lock_guard<std::mutex> lock(the_mutex);
the_queue.push_front(std::move(data));
}

bool empty() const
{
std::lock_guard<std::mutex> lock(the_mutex);
return the_queue.empty();
}

bool try_pop(data_type& res) // 3
{
std::lock_guard<std::mutex> lock(the_mutex);
if(the_queue.empty())
{
return false;
}

res=std::move(the_queue.front());
the_queue.pop_front();
return true;
}

bool try_steal(data_type& res) // 4
{
std::lock_guard<std::mutex> lock(the_mutex);
if(the_queue.empty())
{
return false;
}

res=std::move(the_queue.back());
the_queue.pop_back();
return true;
}
};

这个队列对std::deque<fuction_wrapper>进行了简单的包装①,就能通过一个互斥锁来对所有访问进行控制了。push()②和try_pop()③对队列的前端进行操作,try_steal()④对队列的后端进行操作。

这就说明每个线程中的“队列”是一个后进先出的栈,最新推入的任务将会第一个执行。从缓存角度来看,这将对性能有所提升,因为任务相关的数据一直存于缓存中,要比提前将任务相关数据推送到栈上好。同样,这种方式很好的映射到某个算法上,例如:快速排序。之前的实现中,每次调用do_sort()都会推送一个任务到栈上,并且等待这个任务执行完毕。通过对最新推入任务的处理,就可以保证在将当前所需数据块处理完成前,其他任务是否需要这些数据块,从而可以减少活动任务的数量和栈的使用次数。try_steal()从队列末尾获取任务,为了减少与try_pop()之间的竞争;使用技术来让try_pop()和try_steal()并发执行。

OK,现在拥有了一个很不错的任务队列,并且支持窃取;那这个队列将如何在线程池中使用呢?这里简单的展示一下。

使用任务窃取的线程池

class thread_pool
{
  typedef function_wrapper task_type;

  std::atomic_bool done;
  thread_safe_queue<task_type> pool_work_queue;
  std::vector<std::unique_ptr<work_stealing_queue> > queues;  // 1
  std::vector<std::thread> threads;
  join_threads joiner;

  static thread_local work_stealing_queue* local_work_queue;  // 2
  static thread_local unsigned my_index;

  void worker_thread(unsigned my_index_)
  {
    my_index=my_index_;
    local_work_queue=queues[my_index].get();  // 3
    while(!done)
    {
      run_pending_task();
    }
  }

  bool pop_task_from_local_queue(task_type& task)
  {
    return local_work_queue && local_work_queue->try_pop(task);
  }

  bool pop_task_from_pool_queue(task_type& task)
  {
    return pool_work_queue.try_pop(task);
  }

  bool pop_task_from_other_thread_queue(task_type& task)  // 4
  {
    for(unsigned i=0;i<queues.size();++i)
    {
      unsigned const index=(my_index+i+1)%queues.size();  // 5
      if(queues[index]->try_steal(task))
      {
        return true;
      }
    }
    return false;
  }

public:
  thread_pool():
    done(false),joiner(threads)
  {
    unsigned const thread_count=std::thread::hardware_concurrency();

    try
    {
      for(unsigned i=0;i<thread_count;++i)
      {
        queues.push_back(std::unique_ptr<work_stealing_queue>(  // 6
                         new work_stealing_queue));
        threads.push_back(
          std::thread(&thread_pool::worker_thread,this,i));
      }
    }
    catch(...)
    {
      done=true;
      throw;
    }
  }

  ~thread_pool()
  {
    done=true;
  }

  template<typename FunctionType>
  std::future<typename std::result_of<FunctionType()>::type> submit(
    FunctionType f)
  { 
    typedef typename std::result_of<FunctionType()>::type result_type;
    std::packaged_task<result_type()> task(f);
    std::future<result_type> res(task.get_future());
    if(local_work_queue)
    {
      local_work_queue->push(std::move(task));
    }
    else
    {
      pool_work_queue.push(std::move(task));
    }
    return res;
  }

  void run_pending_task()
  {
    task_type task;
    if(pop_task_from_local_queue(task) ||  // 7
       pop_task_from_pool_queue(task) ||  // 8
       pop_task_from_other_thread_queue(task))  // 9
    {
      task();
    }
    else
    {
      std::this_thread::yield();
    }
  }
};

第一个不同在于,每个线程都有一个work_stealing_queue,而非只是普通的std::queue<>②。当每个线程被创建,就创建了一个属于自己的工作队列⑥,每个线程自己的工作队列将存储在线程池的全局工作队列中①。列表中队列的序号,会传递给线程函数,然后使用序号来索引对应队列③。这就意味着线程池可以访问任意线程中的队列,为了给闲置线程窃取任务。run_pending_task()将会从线程的任务队列中取出一个任务来执行⑦,或从线程池队列中获取一个任务⑧,亦或从其他线程的队列中获取一个任务⑨。

pop_task_from_other_thread_queue()④会遍历池中所有线程的任务队列,然后尝试窃取任务。为了避免每个线程都尝试从列表中的第一个线程上窃取任务,每一个线程都会从下一个线程开始遍历,通过自身的线程序号来确定开始遍历的线程序号。

特别是还没有探究动态变换大小的线程池,即使线程被阻塞的时候(例如:I/O或互斥锁),程序都能保证CPU最优的使用率。

4.延伸拓展


4.1 线程与进程/并发与并行


方式: 双核及其的真正并行、单核机器的任务切换

并发的两种基本途径: 多进程并发、多线程并发

多进程并发: 优点是更容易编写安全的并发代码(操作系统为进程通信提供了一定的保护措施)、可分布式(可以通过远程连接的方式在不同的计算机上独立运行进程);缺点是进程开销大、启动慢,进程之前的通信复杂耗时。

多线程并发: 优点是共享内存的灵活性(进程中的所有线程共享内存地址空间。虽然进程之前也共享内存,但这种共享通常是难以管理的,因为同一数据的内存地址在不同的进程中是不同的),缺点是编写代码时工作量大(需要保证多个线程访问到的共享数据是一致的)

结论是,多个进程(每个进程只包含单一线程)比多个线程(单一进程包含的多个线程)的开销大,若不考虑共享内存所带来的问题,多线程将会成为主流语言更加青睐的并发途径。

并发与并行: 对于多线程来说,两者概念大部分重叠,意思近乎相同,只是侧重点不同,关注于使用当前可用硬件来提高批量数据处理的速度时,我们讨论程序的并行性,关注于任务分离或任务响应时,就会讨论到程序的并发性。(我的理解:并发概念中涵盖了并行)

4.2 创建线程时的传参问题分析


如“std::thread th1(proc1)”,创建线程时需要传递函数名作为参数,提供的函数对象会复制到新的线程的内存空间中执行与调用。

如果用于创建线程的函数为含参函数,那么在创建线程时,要一并将函数的参数传入。常见的,传入的参数的形式有基本数据类型(int,char,string等)、引用、指针、对象这些,下面总结了传递不同形式的参数时std::thread类的处理机制,以及编写程序时候的注意事项。本章节只给出了部分示例代码,没有必要为了证明处理机制而举例大量简单代码而使得文章冗长,但是推荐新手自行编写程序研究。

总体来说,std::thread的构造函数会拷贝传入的参数:

当传入参数为基本数据类型(int,char,string等)时,会拷贝一份给创建的线程;
2. 当传入参数为指针时,会浅拷贝一份给创建的线程,也就是说,只会拷贝对象的指针,不会拷贝指针指向的对象本身。

3. 当传入的参数为引用时,实参必须用ref()函数处理后传递给形参,否则编译不通过,此时不存在“拷贝”行为。引用只是变量的别名,在线程中传递对象的引用,那么该对象始终只有一份,只是存在多个别名罢了(注意把引用与指针区别开:指针是一块内存指向另一块内存,指针侧重“指向”二字;引用是只有一块内存,存在多个别名。理解引用时不要想着别名“指向”内存,这是错误的理解,这样的理解会导致分不清指针和引用,别名与其本体侧重于“一体”二字,引用就是本体,本体就是引用,根本没有“指向”关系。);

#include<thread>
#include<iostream>
using namespace std;
void proc(int& x)
{
    cout << x <<","<<&x<<endl;
}

int main()
{
    int a=10;
    cout<< a <<",,"<<&a<<endl;
    thread t1(proc,ref(a));
    t1.join();
    return 0;
} 

4.3detach()

当传入的参数为类对象时,会拷贝一份给创建的线程。此时会调用类对象的拷贝构造函数。
使用detach()时,可能存在主线程比子线程先结束的情况,主线程结束后会释放掉自身的内存空间;在创建线程时,如果std::thread类传入的参数含有引用或指针,则子线程中的数据依赖于主线程中的内存,主线程结束后会释放掉自身的内存空间,则子线程会出现错误。

5. 实现多线程安全的队列


STL容器不是线程安全的,基于STL的dequeue容器, 利用上面所学的多线程知识,来实现一个线程安全的双端队列。

#pragma once
#include <mutex>
#include <deque>
#include <condition_variable>
#include <thread>
#include <chrono>
#include <functional>

#ifndef NDEBUG
#define DEBUG_PRINTF(...) printf(__VA_ARGS__)//__VA_ARGS__:用于在宏替换部分中,表示可变参数列表
#else
#define DEBUG_PRINTF(...) ((void)0)
#endif

using Callable = std::function<void()>;

template<typename T>
class SafeDequeue {
public:
    SafeDequeue() = default;
    ~SafeDequeue() = default;
    SafeDequeue(const SafeDequeue&) = delete;
    SafeDequeue& operator=(const SafeDequeue&) = delete;

private:
    using unique_lock = std::unique_lock<std::mutex>;

public:
    template<typename _Fun>
    void push_front(_Fun&& callback);//万能引用

    template<typename _Fun>
    void push_back(_Fun&& callback);//万能引用

    void pop_front(T& callback);
    void pop_back(T& callback);

    bool pop_front(T& callback, uint32_t timeout);
    bool pop_back(T& callback, uint32_t timeout);

    void swap(T& right);
    size_t size() const;
    bool empty() const;
    void clear();

private:
    std::deque<T> mDequeue;
    mutable std::mutex mMutex;//常成员函数不能改变类的成员变量的值,除非该成员变量为mutable
    std::condition_variable mCond;
};

#include "SafeDequeue.inl"


#include "SafeDequeue.h"

template<typename T>
template<typename _Fun>
inline void SafeDequeue<T>::push_front(_Fun&& callback)
{
    unique_lock lock(mMutex);
    mDequeue.emplace_front(std::forward<_Fun>(callback));//原地构造,完美转发
    lock.unlock();

    DEBUG_PRINT("push_front\n");
    mCond.notify_one();
}

template<typename T>
template<typename _Fun>
inline void SafeDequeue<T>::push_back(_Fun&& callback)
{
    unique_lock lock(mMutex);
    mDequeue.emplace_back(std::forward<_Fun>(callback));//原地构造,完美转发
    lock.unlock();

    DEBUG_PRINT("push_back\n");
    mCond.notify_one();
}

template<typename T>
inline void SafeDequeue<T>::pop_front(T& callback)
{
    unique_lock lock(mMutex);
    mCond.wait(lock, [this] {
        return !mDequeue.empty();
        });

    callback = std::move(mDequeue.front());
    mDequeue.pop_front();
    DEBUG_PRINT("pop_front\n");
}

template<typename T>
inline bool SafeDequeue<T>::pop_front(T& callback, uint32_t timeout)
{
    unique_lock lock(mMutex);
    const bool result = mCond.wait_for(lock, std::chrono::milliseconds(timeout), [this] {
        return !mDequeue.empty();
        });

    if (result) {
        callback = std::move(mDequeue.front());
        mDequeue.pop_front();
    }
    DEBUG_PRINT("pop_front\n");
    return result;
}

template<typename T>
inline void SafeDequeue<T>::pop_back(T& callback)
{
    unique_lock lock(mMutex);
    mCond.wait(lock, [this] {
        return !mDequeue.empty();
        });

    callback = std::move(mDequeue.back());
    DEBUG_PRINT("pop_back\n");
    mDequeue.pop_back();
}

template<typename T>
inline bool SafeDequeue<T>::pop_back(T& callback, uint32_t timeout)
{
    unique_lock lock(mMutex);
    const bool result = mCond.wait_for(lock, std::chrono::milliseconds(timeout), [this] {
        return !mDequeue.empty();
        });

    if (result) {
        callback = std::move(mDequeue.back());
        mDequeue.pop_back();
    }
    DEBUG_PRINT("pop_back\n");
    return result;
}

template<typename T>
inline void SafeDequeue<T>::swap(T& right)
{
    unique_lock lock1(mMutex, std::adopt_lock);
    unique_lock lock2(right.mMutex, std::adopt_lock);
    std::lock(lock1, lock2);
    mDequeue.swap(right.mDequeue);
    DEBUG_PRINT("swap\n");
}

template<typename T>
inline size_t SafeDequeue<T>::size() const
{
    std::unique_lock<std::mutex> mlock(mMutex);//常成员函数不能改变类的成员变量的值,除非该成员变量为mutable
    DEBUG_PRINT("size\n");
    return mDequeue.size();
}

template<typename T>
inline bool SafeDequeue<T>::empty() const
{
    std::unique_lock<std::mutex> mlock(mMutex);
    DEBUG_PRINT("empty\n");
    return mDequeue.empty();
}

template<typename T>
inline void SafeDequeue<T>::clear()
{
    std::unique_lock<std::mutex> mlock(mMutex);
    DEBUG_PRINT("clear\n");
    return mDequeue.clear();
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值