多线程开发之线程基础(实现线程池必备知识)

前言

基础知识

我们在用C++进行多线程编程的时候,可以使用内核的同步原语进行自己的封装,也可以使用C++11已经封装好的,因为我觉得有必要了解一些底层的东西,所以这两个内容我都会讲到。

《Linux多线程编程》中提到的线程同步四项原则

  1. 首要原则是尽量最低限度的共享原则,减少同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑immutable对象,实在不行才暴露可修改的对象,并且用同步措施来充分保护它。
  2. 其次是使用高级的并发编程控件,比如 TaskQueueProducer-Consumer QueueCountDownLatch等等。
  3. 最后不得已必须使用底层同步原语时,只用非递归的互斥量和条件变量,慎用读写锁,不要用信号量。
  4. 除了使用atomic整数之外,不要自己编写lock-free的代码,也不要用内核级同步原语。

内核-同步原语

同步原语包括互斥量、条件变量、读写锁、信号量、文件互斥,但是在这里我只介绍互斥量和条件变量。

1. 互斥量

互斥量保护了临界区,任何一个时刻最多只能有一个线程在此使用临界资源,这样就做到了保护临界区。

在使用互斥锁的时候,需要注意一些事项(均出自《Linux多线程编程》)

  1. 使用RAII手法封装mutex的创建、销毁、加锁、解锁的四个操作。
  2. 不手工的调用lock和unlock函数,一切交给栈上的Guard对象的构造和析构函数负责,Guard对象的生命期正好等于临界区。避免在一个函数里面加锁,在另一个函数里面解锁,也避免在不同的分支加锁和解锁。
  3. 在每次构造Guard对象的时候,思考已经持有的锁,防止因为加锁的顺序的不同而导致死锁。
创建和销毁
// 初始化
int pthread_mutex_init (pthread_mutex_t *__mutex, __const pthread_mutexattr_t *__mutexattr);  

// 销毁
int pthread_mutex_destroy (pthread_mutex_t *__mutex);

在初始化中的第二个参数是设置线程的属性,如果默认则设置为NULL即可。

设置属性
// 初始化互斥量属性对象
int pthread_mutexattr_init (pthread_mutexattr_t *__attr);  

// 销毁互斥量属性对象
int pthread_mutexattr_destroy (pthread_mutexattr_t *__attr);

可以发现属性和互斥量的创建和销毁是类似的。

使用
// 阻塞到该互斥量解锁为止
int pthread_mutex_lock(pthread_mutex_t *mutex);

// 不会阻塞,互斥量被占用则返回EBUSY的错误
int pthread_mutex_trylock(pthread_mutex_t *mutex);

// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
封装
class TMutex {
public:
    TMutex();
    ~TMutex();

    void lock();
    void unlock();

    inline pthread_mutex_t* getMutext() { return &_mutex; }

private:
    pthread_mutex_t _mutex;  // 不可以直接操作
};

TMutex::TMutex() {
    pthread_mutex_init(&this->_mutex, NULL);
}

TMutex::~TMutex() {
    pthread_mutex_destroy(&this->_mutex);
}

void TMutex::lock() {
    pthread_mutex_lock(&this->_mutex);
}

void TMutex::unlock() {
    pthread_mutex_unlock(&this->_mutex);
}

class TMutexGuard {
public:
    explicit TMutexGuard(const TMutex& mutex) : _mutex(mutex) {
        _mutex.lock();
    }

    ~TMutexGuard() {
        _mutex.unlock();
    };

private:
    TMutex _mutex;
};


在使用的时候要通过Guard去操作mutex。类似于这样:

TMutexGuard gaurd(this->_mutex);
注意

死锁通常发生在多个锁相互依赖的时候,比如说,有两个锁A和B,锁A占用了资源A使用共享资源,企图使用共享资源B,此时锁B占用了资源B并且等待使用资源A,如果双方都不想让,最后的结果就是谁也访问不了资源而高高挂起。

预防这种问题的关键在于,应该按照一定的顺序来申请资源,释放资源,不能同时的去对访问资源进行加锁,这样谁都访问不了,还有另外一种方法,就是使用非阻塞的模式,不使用lock,而是使用try_lock,避免一直阻塞在原地,而是返回EBUSY(锁尚未解除)或者EINVAL(锁变量不可用),将自己拿到的锁进行释放,过段时间再试试看。

所以在设计代码的时候,应当尽量减少同一个临界区的锁的数量,因为锁一多,就会出现各种各样的周边问题,还有在命名锁的时候可以加上序号,这样可以很清楚的知道谁先谁后。

2. 条件变量

互斥锁是加锁原语,如果需要等待某一个条件成立的时候,则应该使用条件变量;条件变量必须配合互斥锁一起使用。比如说我们在便利店买东西的时候,我们要在拿好东西以后去告诉店员我们要付帐,在付完帐后店员就可以继续做自己的事情,可以把我们买东西等这个看作是一个任务,店员看做是一个线程,这个线程要去帮我们处理各种各样的任务,如果有任务来的时候,通知线程处理,当任务很多很多的时候,任务就会进入到队列中(就像排队,谁先来处理谁),挨个处理,如果没有任务,线程就等待,这就是线程池的原理

创建和销毁
// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond, 			const pthread_condattr_t *restrict attr);

// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);

同样类似于mutex,也可以对条件变量设置属性。

使用
// 通知一个线程
int pthread_cond_signal(pthread_cond_t *cond);

// 通知所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);

// 阻塞该线程直到被唤醒
int pthread_cond_wait(pthread_cond_t *restrict cond, 			pthread_mutex_t *restrict mutex);

// 在前者的基础上,加上了时间的限制
int pthread_cond_timedwait(
pthread_cond_t *restrict cond, 
pthread_mutex_t *restrict mutex, 
const struct timespec *restrict abstime);

在阻塞的时候,需要传递互斥锁,用来保护条件,以防止多个线程同时请求的竞争;在调用waiting函数之前,必须要申请互斥锁,在进入到waiting的时候,才释放互斥锁,直到条件收到信号,当调用返回的时候,互斥对象再次被锁定。

waiting函数应当放在while循环中,因为需要考虑到可能会被意外唤醒,却不满足条件的时候。这就是所谓的虚假唤醒

一般的代码编写格式为:

  • wait端

  • signal端或者brocast端

    看到有些博客中提到的wait morphing-先通知后解锁,因为先通知,wait端被唤醒以后发现想要占用锁,但是发现还没有解锁,所以又进入到了等待,在signal端解锁以后wait端才能占用锁来处理。这里涉及到一个顺序的问题,好像两种方法的结果都是差不多的,但是个中差别如果有人能给我说说更好了😄。

封装
class TConditon {
public:
    TConditon(TMutex mutex);
    ~TConditon();

    void wait();  // 阻塞直到有notify通知
    void wait_for(double time);  // 可以设置时间
    void notify_one();  // 唤醒某个线程
    void notify_all();  // 唤醒全部线程

private:
    pthread_cond_t _cond;
    TMutex _mutex;
};

//------------------------------------------------------//

TConditon::TConditon(TMutex mutex) : _mutex(mutex) {
    pthread_cond_init(&this->_cond, nullptr);
}

TConditon::~TConditon() {
    this->_mutex.unlock();
    pthread_cond_destroy(&this->_cond);
}

void TConditon::wait() {
    pthread_cond_wait(&this->_cond, this->_mutex.getMutext());
}

void TConditon::wait_for(double time) {
    this->_mutex.lock();
    struct timespec spec;
    spec.tv_sec = static_cast<time_t >(time/1000);  // 秒
    spec.tv_nsec = 0;  // 毫秒
    pthread_cond_timedwait(&this->_cond, this->_mutex.getMutext(), &spec);
}

void TConditon::notify_one() {
    pthread_cond_signal(&this->_cond);
}

void TConditon::notify_all() {
    pthread_cond_broadcast(&this->_cond);
}
注意

条件变量通常用来实现高层的阻塞队列(线程池中的实现就是)或者倒时器;

倒时器主要有两种用途:

  • 主线程发起多个子线程,并且在等待全部子线程完成一定的任务后,主线程才继续执行,通常可以用在主线程等待多个子线程完成初始化;
  • 主线程同样发起多个子线程,并且在等待主线程完成一定的任务以后,多个子线程才继续执行,通常可以用于多个子线程等待主线程发出起跑的命令。

读写锁和信号量

在陈硕的书中提到,不建议使用读写锁和信号量,因为一般情况下普通mutex和条件变量已经足够,所以这个打算用到再说。

线程

前面讲的都需要基于线程的基础,下面将介绍在Unix下,线程的使用方法,其头文件为#include <pthread.h>

线程状态转换图

这是JAVA中的线程转换图,可以发现,线程一共有四种状态,分别是就绪状态,阻塞状态,运行状态,终止状态。

  • 就绪状态:线程可以运行,此时等待系统调用,也就是上图中的可运行;

  • 运行状态:因为系统会为每个线程分配一个时间段,如果在这个时间片还没有执行完毕,那么系统会将这个线程状态保存下来,同时让下一个线程来执行,也就是这个线程被抢占了,则会到可运行的状态;那么如果没有足够的线程执行任务对象的时候,则会让任务进入等待队列,等待分配一个线程去处理;

  • 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。

      阻塞的情况分三种:1. 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。(wait方法会释放占用资源)2. 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。3. 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
    

创建和终止

// 创建
int pthread_create(pthread_t *thread_id, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

// 终止(主动的行为)
void pthread_exit(void *retval);

// 终止(在同一进程内的线程可以指定另一个线程退出)
int pthread_cancel(pthread_t thread);

其中thread_id为线程的标识符,attr为线程的属性,start_routine表示线程一旦建立就会执行的函数,arg为传递给函数start_routine的参数。我们可以在函数中调用pthread_self获取调用这个函数的线程的标识符。

连接和分离

// 连接
int pthread_join(pthread_t thread, void **retval);
 
 // 分离
int pthread_detach(pthread_t thread);

在我们创建线程的时候,有一个属性用来指定是连接的还是分离的,只有定义为非分离的才可以连接,否则会报错,通常我们都会设置为NULL,默认就是非分离的。

线程之间是共享数据段的,因此通常在线程退出以后,退出线程所占用的资源并不会随着线程的退出而被释放,所以这个时候,可以使用pthread_join来同步释放资源,调用该函数的线程将会挂起等待,直到终止。

两者的差别在于,join会等待所有的线程都处理资源完毕,才会将这个没有任何线程使用的资源给释放掉,也就是说,一个线程执行join以后,其它的线程可以使用它的资源,因为它的资源还没有被系统释放掉;但是detach却不一样,调用该函数的线程终止以后系统立马收回其资源。注意,这两个函数不能够同时使用。

主线程和普通线程

在C语言的程序中,main就是一个主线程,主线程发散多个子线程,而这些子线程就是普通线程。

主线程和普通线程的区别在于:

  • 主线程返回或者运行结束时(执行return,exit等),所有的线程不管有没有执行完都要退出,但是普通线程不会。所以我们如果想要主线程等待其它线程结束以后才退出,通常使用的方法是pthread_join,这时调用的主线程会被阻塞,直到其它被join的线程执行结束以后才会往下执行。
  • 一般主线程的栈的大小比普通现成的大很多。主线程使用的是进程的栈,所以会比较大。
  • 主线程的main函数是被程序在对进程进行初始化后调用,而普通函数则是通过start函数调用。

封装

.h文件

#ifndef TICKLE_TTHREAD_H
#define TICKLE_TTHREAD_H

#include <iostream>
#include <pthread.h>
#include <functional>
#include <string.h>
#include <unistd.h>
#include <exception>
#include <memory>

namespace Tickle {

    typedef std::function<void()> TThreadFunc;
    struct TThreadData {
        TThreadFunc _func;
        std::weak_ptr<pid_t> _pid;
        std::string _name;


        TThreadData(const TThreadFunc& func, const std::shared_ptr<pid_t>& pid, const std::string& name) : _func(func), _pid(pid), _name(name) {}

        void runInThread() {
            try {
                if (_func == nullptr) {
                    std::cout << "function is null" << std::endl;
                }
                else {
                    _func();
                }
            }
            catch (std::exception &e) {
                std::cout << "Thread error info:" << e.what() << std::endl;
            }
        }

    };

    class TThread {
    public:
        TThread(const TThreadFunc& func, const std::string& name = std::string());
        TThread(const TThreadData& data);
        ~TThread();

        void start();  // 创建线程
        void join();
        inline const std::string& name() const { return _name; }

    private:
        std::string _name;
        pthread_t _thd;
        std::shared_ptr<pid_t> _pid;
        TThreadFunc _func;
    };

}

#endif //TICKLE_TTHREAD_H

.cpp文件

#include "TThread.h"

namespace Tickle {

    void* startInThread(void* param) {
        TThreadData* data = static_cast<TThreadData*>(param);
        data->runInThread();
        delete(data);
        return nullptr;
    }

    TThread::TThread(const TThreadFunc& func, const std::string& name) : _func(func), _name(name), _thd(), _pid(new pid_t(0))
    {

    }

    TThread::TThread(const TThreadData &data) : _func(data._func), _name(data._name), _thd(0), _pid(new pid_t(0))
    {

    }

    TThread::~TThread() {
        pthread_detach(this->_thd);
    }

    void TThread::start() {
        TThreadData * data = new TThreadData(this->_func, this->_pid, this->_name);
        if (0 != pthread_create(&this->_thd, nullptr, startInThread, data)) {
            std::cout << "create the thread failed" << std::endl;
            return ;
        }
    }

    void TThread::join() {
        if (0 != pthread_join(this->_thd, nullptr)) {
            std::cout << "join the thread failed" << std::endl;
        }
    }
}

内存池的实现

内存池包括以下重点:

  1. 先申请一定数量的线程;
  2. 添加任务进入队列,并且通知线程来处理;
  3. 线程在从队列中选择任务的时候,按照队列先入先出的顺序来选择,如果队列中没有任何任务,条件变量则等待。

最后

我这里就不把我实现的给贴出来,大家可以自己实现😄,还有什么错误请指出来,转载请注明出处,谢谢。

posted @ 2017-05-13 21:28  banananana  阅读(1796)  评论(6编辑  收藏  举报