C++ Linux轻量级WebServer(二)并发模型

2,067 阅读5分钟

介绍

WebServer采用I/O复用技术Epoll与线程池实现的多线程Reactor高并发模型,下面则来介绍Socket、Epoll、线程池与Reactor技术,及在项目中的实现方式。

Socket

先建立TCP服务端通信,得到监听的文件描述符,再去等待客户端的连接,以此来建立双方的通信,以下是Socket通信流程:

Socket通信流程.png

Socket接口使用与实现方式(为简化代码,未对异常处理):

#include <sys/socket.h>
#include <netinet/in.h>

listenFd_ = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port_);

int optval = 1;
/* 端口复用 */
ret = setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, (const void*)&optval, sizeof(int));

ret = bind(listenFd_, (struct sockaddr *)&addr, sizeof(addr));
ret = listen(listenFd_, 6);

Epoll

使用I/O复用技术Epoll以同时监听到多个文件描述符,提高程序I/O的并发量,而为什么不用Select与Poll,可见一文读懂社长的TinyWebServer的IO复用方式这一部分。

Linux下Epoll接口使用与实现方式:

以下为epoller.h

#ifndef EPOLLER_H
#define EPOLLER_H

#include <sys/epoll.h> //epoll_ctl()
#include <fcntl.h>  // fcntl()
#include <unistd.h> // close()
#include <assert.h> // close()
#include <vector>
#include <errno.h>

class Epoller {
public:
    explicit Epoller(int maxEvent = 1024);
    ~Epoller();

    bool AddFd(int fd, uint32_t events);
    bool ModFd(int fd, uint32_t events);
    bool DelFd(int fd);

    int Wait(int timeoutMs = -1);

    int GetEventFd(size_t i) const;
    uint32_t GetEvents(size_t i) const;
        
private:
    int epollFd_;   // epoll_create()创建一个epoll对象,返回值就是epollFd
    std::vector<struct epoll_event> events_;     // 检测到的事件的集合 
};

#endif //EPOLLER_H

以下为epoller.cpp

#include "epoller.h"

// 创建epoll对象 epoll_create(512)
Epoller::Epoller(int maxEvent):epollFd_(epoll_create(512)), events_(maxEvent){
    assert(epollFd_ >= 0 && events_.size() > 0);
}

Epoller::~Epoller() {
    close(epollFd_);
}

// 添加文件描述符到epoll中进行管理
bool Epoller::AddFd(int fd, uint32_t events) {
    if(fd < 0) return false;
    epoll_event ev = {0};
    ev.data.fd = fd;
    ev.events = events;
    return 0 == epoll_ctl(epollFd_, EPOLL_CTL_ADD, fd, &ev);
}

// 修改
bool Epoller::ModFd(int fd, uint32_t events) {
    if(fd < 0) return false;
    epoll_event ev = {0};
    ev.data.fd = fd;
    ev.events = events;
    return 0 == epoll_ctl(epollFd_, EPOLL_CTL_MOD, fd, &ev);  // 此时修改了事件的模式如EPOLLOUT
}

// 删除
bool Epoller::DelFd(int fd) {
    if(fd < 0) return false;
    epoll_event ev = {0};
    return 0 == epoll_ctl(epollFd_, EPOLL_CTL_DEL, fd, &ev);
}

// 调用epoll_wait()进行事件检测
int Epoller::Wait(int timeoutMs) {
    return epoll_wait(epollFd_, &events_[0], static_cast<int>(events_.size()), timeoutMs);  // events == &events[0]
}

// 获取产生事件的文件描述符
int Epoller::GetEventFd(size_t i) const {
    assert(i < events_.size() && i >= 0);
    return events_[i].data.fd;
}

// 获取事件
uint32_t Epoller::GetEvents(size_t i) const {
    assert(i < events_.size() && i >= 0);
    return events_[i].events;
}

Epoll实例的创建是发生在WebServer的构造函数中,之后会设置监听与通信文件描述符为ET模式,ET模式如下有解释,使用epoll_wait去监听事件,再循环处理每个事件。

ET与IT: Epoll的工作模式有两种,分别是ET模式和IT模式,代表边沿触发和水平触发,边沿触发指委托内核检测读数据,当读缓冲区有数据时,Epoll检测到了会给用户通知,这时用户需一次性将数据全部读取完,因为下次Epoll检测时不会再通知,而水平触发则如果用户不读数据或者只读了一部分数据,下次Epoll检测时仍会触发,由此可看出ET是一种高速的工作方式,IT是一种缺省的工作方式,而项目中采取的是ET模式。

以下为webserver.cpp的循环事件处理:

int eventCnt = epoller_->Wait(timeMS);

for(int i = 0; i < eventCnt; i++) {
    int fd = epoller_->GetEventFd(i);   
    uint32_t events = epoller_->GetEvents(i);   
    if(fd == listenFd_) {    
        DealListen_();
    }                           
    else if(events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
        assert(users_.count(fd) > 0);
        CloseConn_(&users_[fd]);
    }
    else if(events & EPOLLIN) {
        assert(users_.count(fd) > 0);
        DealRead_(&users_[fd]);
    }        
    else if(events & EPOLLOUT) {   
        assert(users_.count(fd) > 0);
        DealWrite_(&users_[fd]);
    } 
    else {
        LOG_ERROR("Unexpected event");
    }
}

线程池

ThreadPool的创建是发生在WebServer的构造函数中,并指定工作线程的线程数,在ThreadPool的构造函数中,循环创建工作线程并执行task()函数用来处理任务队列中的读写事件,为了避免过度占用cpu,采用条件变量的方式唤醒或阻塞工作线程,而任务队列则需要加锁,因为它被所有线程所共享,线程也会被设置为脱离状态即detach状态,以至于线程运行结束时,资源会被系统自动回收,无需再对线程join操作。

使用线程池而不是使用多线程即来一个请求创建一个线程,执行完又将该线程销毁,是为了避免由于线程频繁的创建和销毁所带来的性能消耗,而为了任务的并发执行,将任务交给线程池即可。

线程池中线程的数量大小:最佳线程数 = cpu核数 * 当前cpu利用率 * (1 + cpu等待时间 / cpu处理时间)

线程池.png

以下是threadpool.h

#ifndef THREADPOOL_H
#define THREADPOOL_H

#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <functional>
class ThreadPool {
public:
    explicit ThreadPool(size_t threadCount = 8): pool_(std::make_shared<Pool>()) {  // explicit防止构造函数进行隐式类型转换
            assert(threadCount > 0);

            // 创建threadCount个子线程
            for(size_t i = 0; i < threadCount; i++) {
                std::thread([pool = pool_] {
                    std::unique_lock<std::mutex> locker(pool->mtx);
                    while(true) {
                        if(!pool->tasks.empty()) {
                            // 从任务队列中取第一个任务
                            auto task = std::move(pool->tasks.front());
                            // 移除掉队列中第一个元素
                            pool->tasks.pop();
                            locker.unlock();
                            task();
                            locker.lock();  // 这里是对工作队列加锁
                        } 
                        else if(pool->isClosed) break;
                        else pool->cond.wait(locker);   // 如果队列为空,等待
                    }
                }).detach();// 线程分离
            }
    }

    ThreadPool() = default;

    ThreadPool(ThreadPool&&) = default;
    
    ~ThreadPool() {
        if(static_cast<bool>(pool_)) {
            {
                std::lock_guard<std::mutex> locker(pool_->mtx);
                pool_->isClosed = true;
            }
            pool_->cond.notify_all();
        }
    }

    template<class F>
    void AddTask(F&& task) {
        {
            std::lock_guard<std::mutex> locker(pool_->mtx);
            pool_->tasks.emplace(std::forward<F>(task));
        }
        pool_->cond.notify_one();   // 唤醒一个等待的线程
    }

private:
    // 结构体
    struct Pool {
        std::mutex mtx;     // 互斥锁
        std::condition_variable cond;   // 条件变量
        bool isClosed;          // 是否关闭
        std::queue<std::function<void()>> tasks;    // 队列(保存的是任务)
    };
    std::shared_ptr<Pool> pool_;  //  池子
};


#endif //THREADPOOL_H

并发模型

Reactor:

主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有就立即将该事件通知给工作线程(逻辑单元),将socket可读可写事件放入请求队列。除此之外,主线程不做任何其它实质性的工作。读写数据,接收新的连接,以及处理客户请求均在工作线程中完成。

Reactor、Proactor以及模拟Proactor的区别可见一文读懂社长的TinyWebServer的事件模式这一部分。

Reactor模型.png

该项目的并发模型则采用Reactor模型,但不同的是主线程不仅监听文件描述符是否有事件发生,还负责接收新的客户连接epoll_wait()用来同时监听多个文件描述符,如果是连接请求则会封装成一个HttpConnection对象,并与服务器建立连接,如果是读写事件,采用线程池的方式唤醒其中的工作线程去处理读写事件。