再谈线程池——友好地关闭线程池

来源:互联网 发布:windows dns 主备切换 编辑:程序博客网 时间:2024/05/22 16:06

上一篇博客主要讨论在线程池中使用条件变量实现同步队列和使用信号量实现的性能差异。结论是条件变量的性能高于信号量。
再附上一次源码地址:github地址
在上次的源码中,关闭线程池是ThreadPool通过一把自旋锁实现的。代码如下:

    ~ThreadPool() {        pthread_spin_lock(&_closeSpin);        _taskQueue.wakeAll();        for (size_t i = 0; i < _threads.size(); ++i) {            pthread_join(_threads[i], NULL);        }        _threads.clear();        pthread_spin_unlock(&_closeSpin);        pthread_spin_destroy(&_closeSpin);    }void* ThreadPool::workLoop() {    while (!pthread_spin_trylock(&_closeSpin)) {        pthread_spin_unlock(&_closeSpin);        WorkItemPtr workItemPtr = _taskQueue.pop();        if (workItemPtr) {            workItemPtr->process();        }    }    return NULL;}

在ThreadPool析构函数中首先锁住_closeSpin,然后唤醒线程池中所有线程。被唤醒的线程会尝试锁住_closeSpin,因为之前已经被上锁,所以操作失败,退出循环并结束线程。如果是正常情况下,尝试上锁成功后还需要解锁,除了操作多余外,上锁的一小段时间有可能发生线程切换,另一个线程尝试上锁失败后就会退出,导致线程池实际线程数量减少。
其实这里完全可以采用一个更简单的方法,即使用volatile变量实现。析构函数中只需要将变量置一,workLoop函数中的循环条件为该变量为0。核心代码如下所示:

{ public:    ~ThreadPool() {        _closing = 1;        _taskQueue.wakeAll();        for (size_t i = 0; i < _threads.size(); ++i) {            pthread_join(_threads[i], NULL);        }        _threads.clear();    } private:    volatile int _closing;}void* ThreadPool::workLoop() {    while (!_closing) {        WorkItemPtr workItemPtr = _taskQueue.pop();        if (workItemPtr) {            workItemPtr->process();        }    }    return NULL;}

因为int类型变量的读写在一个总线周期内完成,并且只存在置一和读两种操作,所以上述代码是没有问题的。同时使用一个标志变量进行判断,实现简单,易于理解。不过就最后测试结果来看,和之前使用_closeSpin的效果差不多,其实不难理解。如果不考虑_closeSpin冲突时会让工作线程退出的小bug外,自旋锁比volatile变量多做的就是锁总线的操作了。何况对于我的双核老爷机来说,总线竞争的概率太低了。
其实线程池的关闭逻辑是否非得由ThreadPool类实现呢,显然是否定的。如果同步队列再支持close函数,那么就只需要在关闭线程池时先关闭同步队列,那么所有正在读队列的线程都会收到一个类似EOF的数据,这时让线程退出即可。由于队列中的数据其实是智能指针,因此最理想的EOF就是一个空指针。
实现的核心代码如下(对应git的feature/NullPtrClose分支):

template <typename T>void SyncQueue<T>::close(bool immediate) {    ScopedLocker lock(_qMutex);    T t;    if (immediate) {        _q.push_front(t);    } else {        _q.push_back(t);    }    wakeAll();}

close函数需要传入一个参数,当该参数为true时,空指针被放置于队列前端,意味着被唤醒的线程将立即取得空指针并退出,队列中其余的任务将被丢弃;参数为false时,空指针被放置于队列尾部,线程池会处理完队列中的所有任务后再退出。
空指针作为队列内部判断结束的标志,应该不允许外部push一个空指针,所以push函数需要稍作修改:

template <typename T>void SyncQueue<T>::push(T t) {    if (!t) {        return;    }    {        ScopedLocker lock(_qMutex);        while (_q.size() == _capacity) {            pthread_cond_wait(&_fullCond, &_qMutex);        }        _q.push_back(t);    }    pthread_cond_signal(&_emptyCond);}

为了彻底隐藏空指针作为EOF的实现细节,避免workLoop中判断取得的WorkItemPtr,还需要提供一个名为popUnlessClosed()函数,同时workLoop可以进一步简化:

template<typename T>bool SyncQueue<T>::popUnlessClosed(T &t) {    t = pop();    return t;}void* ThreadPool::workLoop() {    WorkItemPtr workItemPtr;    while (_taskQueue.popUnlessClosed(workItemPtr)) {        workItemPtr->process();    }    return NULL;}

修改之后运行测试程序的CPU占用率可以达到97%以上,而之前大约为93%。两者都是多次运行并观察一段时间后的结果,消除一定的偶然性。

虽然知道这样改动后程序将减少判断一个外部标记(相对于局部变量而言,访问一个堆上的变量将更加耗时,而且这个变量还不能被优化)部分开销,但能有4%的差异还是让我很意外的。

之前的测试程序都是CPU密集型的,接下来测试IO密集型(这里的IO只好使用usleep来模拟),每个SimpleWorkItem最后sleep1ms。由于数组赋值时间远小于1ms,为了充分利用性能,需要把线程池中线程的个数提高到150。最后CPU利用率和之前的测试是一样的。

最后是自卖自夸环节。本线程池最大的优点是利用C++面向对象的特性,使用者完全可以根据自己的需求派生一个WorkItem类,实现它的process接口,对于熟悉面向对象的程序员而言使用极为简单。虽然测试样例都是向线程池中push同一个类型的WorkItem,但实际上并没有限制,完全可以向同一个线程池中push多种WorkItem的派生类。

0 0
原创粉丝点击