Linux Socket编程(三):固定线程数的线程池实现

线程池

本节内容源自:[C++11并发学习之六:线程池的实现]

为什么要使用线程池?

(https://blog.csdn.net/caoshangpa/article/details/80374651)
目前的大多数网络服务器,包括Web服务器、Email服务器以及数据库服务器等都具有一个共同点,就是单位时间内必须处理数目巨大的连接请求,但处理时间却相对较短。如果采用传统的“即时创建,即时销毁”策略,大量线程的创建和销毁本身就有很大的开销。

线程池的出现正是着眼于减少线程本身带来的开销。线程池采用预创建的技术,在应用程序启动之后,将立即创建一定数量的线程(N1),放入线程池中。这些线程都是处于阻塞(Suspended)状态,不消耗CPU,但占用较小的内存空间。当任务到来后,缓冲池选择一个空闲线程,把任务传入此线程中运行。当N1个线程都在处理任务后,缓冲池自动创建一定数量的新线程,用于处理更多的任务。在任务执行完毕后线程也不退出,而是继续保持在池中等待下一次的任务。当系统比较空闲时,大部分线程都一直处于暂停状态,线程池自动销毁一部分线程,回收系统资源。

基于这种预创建技术,线程池将线程创建和销毁本身所带来的开销分摊到了各个具体的任务上,执行次数越多,每个任务所分担到的线程本身开销则越小,不过我们另外可能需要将线程之间同步所带来的开销考虑进去。

此外,线程池能够减少创建的线程个数。通常线程池所允许的并发线程是有上界的,如果同时需要并发的线程数超过上界,那么一部分线程将会等待。而传统方案中,如果同时请求数目为2000,那么最坏情况下,系统可能需要产生2000个线程。

线程池,最简单的就是生产者消费者模型了。池里的每条线程,都是消费者,他们消费并处理一个个的任务,而任务队列就相当于生产者了。

线程池最简单的形式是含有一个固定数量的工作线程来处理任务,典型的数量是std::thread::hardware_concurrency()。下面将实现含有固定数量的工作线程的线程池。

线程池适合场景

事实上,线程池并不是万能的。它有其特定的使用场合。线程池致力于减少线程本身的开销对应用所产生的影响,这是有前提的,前提就是线程本身开销与线程执行任务相比不可忽略。如果线程本身的开销相对于线程任务执行开销而言是可以忽略不计的,那么此时线程池所带来的好处是不明显的,比如对于FTP服务器以及Telnet服务器,通常传送文件的时间较长,开销较大,那么此时,我们采用线程池未必是理想的方法,我们可以选择“即时创建,即时销毁”的策略。
总之线程池通常适合下面的几个场合:

  1. 单位时间内处理任务频繁而且任务处理时间短;
  2. 对实时性要求较高。如果接受到任务后在创建线程,可能满足不了实时要求,因此必须采用线程池进行预创建。

实现固定数目的线程池

线程池实现主要的私有成员变量

std::mutex mtx_;
std::condition_variable cond_;
bool is_shutdown_ = false; // 线程池对象结束标志
std::queue<std::function<void()>> tasks_;  // 任务队列

线程池实现主要的成员函数

fixed_thread_pool(size_t thread_count) // 创建固定数量的线程并执行任务
~fixed_thread_pool() // 设置结束标志为true,并唤醒所有等待的线程
execute(F&& f, Args&&... args) // 产生新任务并加入任务队列

线程池主要的实现原理(基于实现代码-1分析)

  1. 定义一个线程池类,在构造线程池类的对象的时候就预创建固定数量的线程(使用C++11的std::thread类创建线程),并将执行的线程从线程对象中分离,即std::thread(...).detach(),线程对象对象处于unjoinable状态,可以被销毁,这样不用在析构的时候等待所有工作线程结束,就可以销毁线程对象。(detach()后线程的执行与对象分离,执行不受影响,但是主线程结束后这些线程会随着主线程的销毁而被挂起)构造函数传入的参数是预创建线程的数量thread_count。在还没有新的任务到来的时候,即data->tasks_.empty(),线程被阻塞,等待被唤醒notify_one()notify_all(),即data->cond_.wait(lk);
  2. 类中声明并定义一个任务队列,当有新的任务(回调函数)产生时,即auto task = bind(forward<F>(f), forward<Args>(args)...);,将任务加入任务队列,即data_->tasks_.emplace(task);。(注意到加入新任务到任务队列的操作是在建立Socket通信的主线程进行,而线程对象获取任务执行的操作是在线程池预创建的若干线程中进行,因此对于对象中共享的任务队列,在进行任务的emplace(task)pop()front()等操作的时候需要上锁才能保证线程的安全。)
  3. 在有新任务加入任务队列后,需要去随机唤醒一个等待的线程去执行该任务,即data_->cond_.notify_one();。如果所有预创建的线程都已经在运行任务,即处于非等待唤醒状态,那么加入的任务会保留在任务队列中,暂时不会被执行。当有线程执行完任务,这个没有判断条件的for (;;) {...}可以使线程可以无限循环执行任务,当判断任务队列还有任务,即!data->tasks_.empty(),会执行之前被阻塞在任务队列中的任务。
  4. 在需要析构线程池对象的时候,将对象的成员变量is_shutdown_设置为true,然后唤醒所有线程,即data_->cond_.notify_all();。此时data->is_shutdown_为true,线程会break,跳出无限的for循环。

实现代码-2与实现代码-1的区别:

  1. 实现了task执行结果的异步访问。将任务函数f以及它的可变模板参数绑定后,使用std::packaged_task作为提供者,存储函数f运行的返回值,计算结果通过std::future::get来获得。
  2. 保存了预创建的 std::thread 对象在一个工作线程组vector<thread> workers;,在析构析的时候,对所有工作线程进行join(),等待工作线程执行结束才可以被销毁。

实现代码-1

代码源自:用 C++ 写线程池是怎样一种体验?

#ifndef FIXED_THREAD_POOL_H
#define FIXED_THREAD_POOL_H

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

class fixed_thread_pool {
    
    
public:
    // 线程池类对象在构造的时候就预创建固定数量的线程,但这里没有保存std::thread对象,而是将创建的线程与主线程分离
    explicit fixed_thread_pool(size_t thread_count)
        : data_(std::make_shared<data>()) {
    
    
        for (size_t i = 0; i < thread_count; ++i) {
    
    
            std::thread([data = data_] {
    
      // 在C++14中,lambda可以捕捉表达式。
                std::unique_lock<std::mutex> lk(data->mtx_);
                for (;;) {
    
    
                    if (!data->tasks_.empty()) {
    
    
                        auto current = std::move(data->tasks_.front());
                        data->tasks_.pop();
                        lk.unlock();
                        current();
                        lk.lock();
                    } else if (data->is_shutdown_) {
    
    
                        break;
                    } else {
    
    
                        data->cond_.wait(lk);   
                    }
                }
            }).detach();
        }
    }

  // 默认构造函数
    fixed_thread_pool() = default;
  // copy
//   fixed_thread_pool(const fixed_thread_pool&) = default;
//   fixed_thread_pool& operator=(const fixed_thread_pool&) = default; 

  // 用户在类中显示定义了移动构造函数,那么没有显示定义的拷贝构造函数和拷贝赋值操作符将定义为"=delete",即不支持拷贝,只支持移动。
  // move
    fixed_thread_pool(fixed_thread_pool&&) = default; 
//   fixed_thread_pool& operator=(fixed_thread_pool&&) = default; 

    // 析构函数
    ~fixed_thread_pool() {
    
    
        // https://zh.cppreference.com/w/cpp/memory/shared_ptr/operator_bool
        if (data_) {
    
     // std::shared_ptr<T>::operator bool,若 *this(共享指针对象) 存储非空指针则为 true ,否则为 false 。
            {
    
    
                std::unique_lock<std::mutex> lk(data_->mtx_);
                data_->is_shutdown_ = true;
            }
            // 通知所有工作线程,唤醒后因为data->is_shutdown_为true了,所以所有线程都会结束运行
            data_->cond_.notify_all();
            // 线程创建的时候已经detach()了,这里不用再对所有线程执行join()
        }
    }

    // 新的task分配线程,没有对task的执行结果进行异步的获取
    template<class F, class... Args>
    void execute(F&& f, Args&&... args) {
    
     
    	// 通用引用”&&“与std::forward<T>()实现参数完美转发到std::bind()函数
        auto task =  bind(forward<F>(f), forward<Args>(args)...);
        {
    
    
            std::unique_lock<std::mutex> lk(data_->mtx_);
            data_->tasks_.emplace(task);
        }
        data_->cond_.notify_one();
    }

 private:
    struct data {
    
    
        std::mutex mtx_;
        std::condition_variable cond_;
        bool is_shutdown_ = false;
        std::queue<std::function<void()>> tasks_;
    };
    std::shared_ptr<data> data_; // 共享指针管理data对象
};

#endif

实现代码-2

代码源自:C++11的简单线程池代码阅读

#ifndef THREAD_POOL_H
#define THREAD_POOL_H

#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future> // packaged_task, future
#include <functional> // std::function,std::bind
#include <memory> // 智能指针
using namespace std;

// 线程池类
class ThreadPool{
    
    
public:
    // 构造函数,传入线程数
    ThreadPool(size_t threads);  
    // 入队任务(传入函数和函数参数)
    template<class F, class... Args>
    auto enqueue(F&& f, Args&&... args)
        -> future<typename result_of<F(Args...)>::type>; 
    // C++11引入了一个叫做尾返回类型(trailing return type),利用auto关键字将返回类型后置

	// 一个最简单的函数包装模板可以这样写(C++11)适用于任何函数(变参、成员都可以)
    // template<class F, class... Args>
    // auto enqueue(F&& f, Args&&... args) -> decltype(declval<F>()(declval<Args>()...)) 
    // {    return f(args...); }

	// C++14开始可以自动推导返回值类型,可以写得更简洁,如下
    // template<class F, class... Args>
    // auto enqueue(F&& f, Args&&... args){    
    //     return f(args...); 
    // }

    // 析构
    ~ThreadPool();
private:

    // 工作线程组
    vector<thread> workers;
    
    // 任务队列
    queue<function<void()>> tasks;

    // synchronization
    mutex queue_mutex; // 队列互斥锁
    condition_variable condition; // 条件变量
    bool stop;  // 线程池停止标志
};

// 构造函数仅启动固定数量线程工作
inline ThreadPool::ThreadPool(size_t threads): stop(false){
    
    
    for(size_t i = 0; i < threads; i++){
    
    
        // 添加线程到工作线程组
        // emplace_back()可以直接通过构造函数的参数构造对象,但前提是要有对应的构造函数。
        // push_back()传入的是对象,emplace_back()传入的是构造函数的参数,效率更高。这里传入一个lambda表达式,作为thread类构造函数的参数。
        workers.emplace_back([this]{
    
    
            // 线程内不断地从任务队列取任务执行
            for(;;){
    
    
                function<void()> task;
                // 括号内定义的变量就只在本域(就是这个大括号)内有效,而且不会影响其他域,即使名字相同
                {
    
    
                    // 拿锁(独占所有权式)
                    unique_lock<mutex> lock(this->queue_mutex);
                    // 等待条件成立(第二个条件为false时阻塞,为true时继续运行)
                    this->condition.wait(lock, [this]{
    
    return this->stop || !this->tasks.empty(); });
                    // 上面一行可以用下面的while循环代替
                    // while(!this->stop && this->tasks.empty()){ // 任务队列为空且线程池为非停止状态,当前线程就无法获取任务,需要等待
                    //     this->condition.wait(lock);  // 阻塞当前线程,等待有新的任务来到的通知
                    // }
                    // 执行条件变量等待的时候,已经拿到了锁(即lock已经拿到锁,没有阻塞)
                    // 这里将会unlock释放锁,其他线程可以继续拿锁,但此处任然阻塞,等待条件成立
                    // 一旦收到其他线程notify_*唤醒,则再次lock,然后进行条件判断
                    // 当[return this->stop || !this->tasks.empty()]的结果为false将阻塞
                    // 条件为true时候解除阻塞。此时lock依然为锁住状态

                    // 如果线程池停止且者任务队列为空,结束返回
                    if(this->stop && this->tasks.empty()){
    
    
                        return;
                    }

                    // 取得任务队首任务,std::move将左值转为右值,移动语义,不用再拷贝一份task?
                    task = move(this->tasks.front());
                    // 从队列移除
                    this->tasks.pop();
                }

                // 执行任务
                task();
            }
        });   
    }
}

// 添加一个新的工作任务到线程池
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> future<typename result_of<F(Args...)>::type >{
    
    
    using return_type = typename result_of<F(Args...)>::type; // using别名指定(c++11),这里绑定后的std::function返回类型为void
    // using return_type = typename invoke_result<F, Args...>::type; // c++17起   

    packaged_task<void()> task(bind(forward<F>(f), forward<Args>(args)...));

    // 将任务函数和其参数绑定,构建一个packaged_task(这里为什么用共享指针管理?)
    // auto task = make_shared< packaged_task<return_type()>>(
    //     bind(forward<F>(f), forward<Args>(args)...)
    // ); 

    // 获取任务的future
    // future<return_type> res = task->get_future();
    future<return_type> res = task.get_future();
    
    {
    
    
        // 独占拿锁
        unique_lock<mutex> lock(queue_mutex);

        // 不允许入列到已经停止的线程池
        if(stop){
    
    
            throw runtime_error("enqueue on stopped ThreadPool");
        }
        // 将任务添加到任务队列
        // tasks.emplace([task]{
    
    
        //     *task)();
        // });

        // 问题1:这里为什么需要引用? 因为与std::promise一样,std::packaged_task支持move,但不支持拷贝(copy),即不能进行值传递。
        // 问题2:为什么直接tasks.emplace(task());不行? 因为std::packaged_task的operator()无返回值,计算结果是通过std::future::get来获取的。
        // tasks元素的类型为function<void()>。这里相当于lambda表达式里嵌套了运行任务task(),然后将返回用std::function存放??
        function<void()> t = [&task]{
    
     task();}; 
        tasks.emplace(t);
    }

    // 发送通知,随机唤醒某一个工作线程取执行任务
    condition.notify_one();
    return res;
}

// the destructor joins all threads
inline ThreadPool::~ThreadPool(){
    
    
    {
    
    
        // 拿锁
        unique_lock<mutex> lock(queue_mutex);
        // 停止标志置true
        stop = true;
    }

    // 通知所有工作线程,唤醒后因为stop为true了,所以都会结束
    condition.notify_all();
    // 等待所有工作线程结束
    for(thread &worker: workers){
    
    
        worker.join();
    }
}
 
#endif

使用线程池实现多个客户端并发请求

#include <iostream>
#include <stdio.h>
#include <cstring>       // void *memset(void *s, int ch, size_t n);
#include <sys/types.h>   // 数据类型定义
#include <sys/socket.h>  // 提供socket函数及数据结构sockaddr
#include <arpa/inet.h>   // 提供IP地址转换函数,htonl()、htons()...
#include <netinet/in.h>  // 定义数据结构sockaddr_in
#include <ctype.h>       // 小写转大写
#include <unistd.h>      // close()、read()、write()、recv()、send()...
#include <thread>
#include "ThreadPool.h"
#include "fixed_thread_pool.h"
using namespace std;

// 处理客户端发送的请求
void requestHandling(const int client_sockfd, const struct sockaddr_in& client_addr);
// 服务端启动:创建套接字socket(),绑定IP地址和端口bind(),监听套接字的端口号listen()。返回服务端的套接字。
int start();


const int flag = 0; // 0表示读写处于阻塞模式
const int port = 8080;
const int buffer_size = 1<<20;

int main(int argc, const char* argv[]){
    
    

    int server_sockfd = start();

    // 创建线程池,有3个预创建的线程
    ThreadPool pool(3);
    // fixed_thread_pool pool(3);

    while(1){
    
    
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        // accept()函数从处于established状态的连接队列头部取出一个已经完成的连接,
        // 如果这个队列没有已经完成的连接,accept()函数就会阻塞当前线程,直到取出队列中已完成的客户端连接为止。
        int client_sockfd = accept(server_sockfd, (struct sockaddr*)&client_addr, &client_len);

        // 将新的任务(处理客户端的请求)加入线程池的任务队列
        pool.enqueue(requestHandling, client_sockfd, client_addr); // 代码-2
        // pool.execute(requestHandling, client_sockfd, client_addr); // 代码-1
    }
    close(server_sockfd);

    return 0;
}

void requestHandling(const int client_sockfd, const struct sockaddr_in& client_addr){
    
    
    char ipbuf[128];
    printf("Connect client iP: %s, port: %d\n", inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ipbuf, 
        sizeof(ipbuf)), ntohs(client_addr.sin_port));

    // 实现客户端发送小写字符串给服务端,服务端将小写字符串转为大写返回给客户端
    char buf[buffer_size];
    while(1) {
    
    
        // read data, 阻塞读取
        int len = recv(client_sockfd, buf, sizeof(buf),flag);
        if (len == -1) {
    
    
            close(client_sockfd);
            // close(server_sockfd);
            perror("read error");
        }else if(len == 0){
    
      // 这里以len为0表示当前处理请求的客户端断开连接
            break;
        }
        printf("Recvive from client iP: %s, port: %d, str = %s\n", inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ipbuf, 
        sizeof(ipbuf)), ntohs(client_addr.sin_port),buf);
        // 小写转大写
        for(int i=0; i<len; ++i) {
    
    
            buf[i] = toupper(buf[i]);
        }
        printf("Send to client iP: %s, port: %d, str = %s\n",inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ipbuf, 
        sizeof(ipbuf)), ntohs(client_addr.sin_port), buf);

        // 大写串发给客户端
        if(send(client_sockfd, buf, strlen(buf),flag) == -1){
    
    
            close(client_sockfd);
            // close(server_sockfd);
            perror("write error");
        }
        memset(buf,'\0',len); // 清空buf
    }
    close(client_sockfd);
    printf("Disconnect client iP: %s, port: %d\n", inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ipbuf, 
        sizeof(ipbuf)), ntohs(client_addr.sin_port));
}

int start(){
    
    
    // 创建服务器监听的套接字。Linux下socket被处理为一种特殊的文件,返回一个文件描述符。
    // int socket(int domain, int type, int protocol);
    // domain设置为AF_INET/PF_INET,即表示使用ipv4地址(32位)和端口号(16位)的组合。
    int server_sockfd = socket(PF_INET,SOCK_STREAM,0);  
    if(server_sockfd == -1){
    
    
        close(server_sockfd);
        perror("socket error!");
    }
    // /* Enable address reuse */
    // int on = 1;
    // int ret = setsockopt( server_sockfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on) );

    // 此数据结构用做bind、connect、recvfrom、sendto等函数的参数,指明地址信息。
    struct sockaddr_in server_addr;
    memset(&server_addr,0,sizeof(server_addr)); // 结构体清零
    server_addr.sin_family = AF_INET;  // 协议
    server_addr.sin_port = htons(port);  // 端口16位, 此处不用htons()或者错用成htonl()会连接拒绝!!
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 本地所有IP
    // 另一种写法, 假如是127.0.0.1
    // inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr.s_addr);


    // int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 
    // bind()函数的主要作用是把ip地址和端口绑定到套接字(描述符)里面
    // struct sockaddr是通用的套接字地址,而struct sockaddr_in则是internet环境下套接字的地址形式,二者长度一样,都是16个字节。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。
    // 一般情况下,需要把sockaddr_in结构强制转换成sockaddr结构再传入系统调用函数中。
    if(bind(server_sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr)) == -1){
    
    
        close(server_sockfd);
        perror("bind error");
    }
    // 第二个参数为相应socket可以排队的准备道来的最大连接个数
    if(listen(server_sockfd, 5) == -1){
    
    
        close(server_sockfd);
        perror("listen error");
    }
    printf("Listen on port %d\n", port);
    return server_sockfd;
}

猜你喜欢

转载自blog.csdn.net/XindaBlack/article/details/105884802