【总结笔记】深度理解 Web Server 技术 —— 补充

优雅关闭连接

https://zhuanlan.zhihu.com/p/436217672

同步 I/O 模拟 proactor 模式

同步 I/O 模型的工作流程如下(以 epoll_wait为例子):

  • 主线程往 epoll 内核事件表注册 socket 上的读就绪事件
  • 主线程调用 epoll_wait 等待 socket 上有数据可读
  • 当 socket 有数据可读时,epoll_wait 通知主线程,主线程从 socket 循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列
  • 某个睡眠的线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核事件表中注册该 socket 上的写就绪事件
  • 主线程调用 epoll_wait 等待 socket 可写
  • 当 socket 有数据可写,epoll_wait 通知主线程。主线程往 socket 上写入服务器处理客户请求的结果

半同步/半反应堆 模式

半同步/半反应堆并发模式是半同步/半异步的变体,将半异步具体化为某种事件处理模式。
半同步/半异步模式工作流程

  • 同步线程用于处理客户逻辑
  • 异步线程用于处理 I/O 事件
  • 异步线程监听到客户请求后,将其封装成请求对象并插入请求队列中
  • 请求队列将通知某个工作在同步模式的工作线程来读取并处理请求对象

半同步/半反应堆模式(以 proactor 模式为例子)

  • 主线程充当异步线程,负责监听所有socket上的事件
  • 若有新请求到来,主线程接受新请求并获得连接 socket,然后往 epoll 内核事件表中注册 socket 上的读写事件
  • 若连接 socket 上有读写事件发生,主线程从 socket 上接收数据,并将数据封装成请求对象插入到请求队列中
  • 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(互斥锁)获得任务的接管权。

线程池的设计模式为半同步/半反应堆,其中反应堆具体为Proactor事件处理模式。
具体的,主线程为异步线程,负责监听文件描述符,接收socket新连接,若当前监听的socket发生了读写事件,然后将任务插入到请求队列。工作线程从请求队列中取出任务,完成读写数据的处理。

补充:并发模式中的同步和异步

  • 同步指的是程序完全按照代码序列的顺序执行
  • 异步指的是程序的执行需要由系统事件驱动

select/poll/epoll

  • 调用函数
    select 和 poll 是一个函数,epoll 是一组函数
  • 文件描述符数量
    (1)select 底层用线性表实现文件描述符集合,个数上线为 1024
    (2)poll 底层用链表实现文件描述符集合,无个数上限
    (3)epoll 底层用红黑树实现文件描述符集合
  • 文件描述符的拷贝
    (1)select 和 poll 通过将所有描述符集合拷贝到内核态,每次调用都需要拷贝
    (2)epoll 通过 epoll_create()建立一棵红黑树,通过 epoll_ctl 将要监听的文件描述符注册到红黑树,通过 epoll_ctl 将要监听的文件描述符注册到红黑树上
  • 内核判断就绪的文件描述符
    (1)select 和 poll 通过遍历文件描述符集合,判断哪个文件描述符上有事件发生
    (2)epoll_create时,内核会在 epoll 文件系统创建一个红黑树以存储文件描述符,还会建立一个 list 链表,用于存储准备就绪的事件,当 epoll_wait调用时,仅仅观察这个 list 链表有无数据即可
  • 工作模式
    (1)select 和 poll 只能工作在相对低效的 LT 模式
    (2)epoll 可以工作在 ET 高效模式,并且 epoll 还支持 EPOLLONESHOT 事件,该事件能进一步减少可读、可写和异常事件触发的次数。【因为 epoll 底层是用红黑树事件,所有使用 LT 模式会比较低效】
  • 应用
    (1)当所有监测的 fd 都是活跃连接且数目较小,使用 select 和 poll【因为 epoll 频繁遍历红黑树效率低】
    (2)当监测的 fd 数目非常大,且单位时间只有其中的一部分 fd 处于就绪状态,使用 epoll 能够明显提升性能

EPOLLONESHOT
一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket;我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件

服务器处理发生事件的逻辑【服务器接收 http 请求】

发生事件包括:①客户连接请求就绪事件;②异常发生事件;③信号发生事件;④读就绪事件

// 创建监听描述符
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
//创建MAX_FD个http类对象
 http_conn* users=new http_conn[MAX_FD];
//创建epoll 描述符
epollfd = epoll_create(5);
// 记住 epoll 描述符
http_conn::m_epollfd = epollfd;
//将监听描述符listenfd挂在epoll树上
addfd(epollfd, listenfd, false);
//创建内核事件表;发生事件会写在events表
epoll_event events[MAX_EVENT_NUMBER];

// 异步线程监听事件的发生【异步线程:调用了系统函数的线程】
while (!stop_server) {
        // 监测注册到 epollfd 事件表的事件,并将已经发生的事件复制到 events 中
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        // 轮询文件描述符
        for (int i = 0; i < number; i++)  {
            int sockfd = events[i].data.fd;
            //处理新到的客户连接
            if (客户连接请求) {
              ...          
              // 获取连接描述符
              int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
              // 初始化连接信息,http连接对象信息包括:读缓冲区、写缓冲区、对方网络信息,在内核对应的连接描述符
              users[connfd].init(connfd, client_address);
            }
            // 处理异常事件
            else if (异常事件) {
              ...
            }
            // 处理信号事件
            else if (信号事件) {
              ...
            }
           // 处理读就绪事件
            else if (读就绪事件) {
                // 此项目使用同步 I/O 模拟 Proactor
                // 主线程读完成后【users[sockfd].read_once()】,选择一个工作线程来处理客户请求
                if (users[sockfd].read_once()) {
                  // 将该事件放到线程池
                  pool->append(users + sockfd);
                }              
                ...
            }
            // 处理写就绪事件 (由谁来注册事件呢?)
           else if (写就绪事件) {
              // 写到对应缓冲区
              users[sockfd].write();
            }
        }
}

处理子线程 处理http请求报文逻辑

处理子线程 处理分别包括 报文解析 任务与 报文响应 任务。

class http_conn
{
public:
    void process();
}

void http_conn::process()
{
    HTTP_CODE read_ret = process_read();
    //NO_REQUEST,表示请求不完整,需要继续接收请求数据
    if (read_ret == NO_REQUEST)
    {
        // 由于设置了 EPOLL_ONE_SHOT,因此需要重新注册 epoll 事件
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        return;
    }
    // 请求读完毕,完成报文响应
    bool write_ret = process_write(read_ret);
    if (!write_ret)
    {
        close_conn();
    }
    // 由于设置了 EPOLL_ONE_SHOT,因此需要重新注册 epoll 事件
    modfd(m_epollfd, m_sockfd, EPOLLOUT);
}

HTTP请求的处理结果:
NO_REQUEST:请求不完整,需要继续读取请求报文数据
GET_REQUEST:获得了完整的HTTP请求
BAD_REQUEST:HTTP请求报文有语法错误
INTERNAL_ERROR:服务器内部错误,该结果在主状态机逻辑switch的default下,一般不会触发

处理子线程 进行报文解析逻辑

从状态机负责读取报文的一行,主状态机负责对该行数据进行解析。

主状态机解析报文 process_read() 逻辑代码:

// 获取 m_read_buf 中一行的数据
char* get_line(){
    return m_read_buf+m_start_line;
}

http_conn::HTTP_CODE http_conn::process_read() {
    // 初始化信息,如果请求不完整,继续监听
    LINE_STATUS line_status = LINE_OK;  
    HTTP_CODE ret = NO_REQUEST;  
    // 循环体,从状态机不断读取一行数据
    while ((m_check_state == CHECK_STATE_CONTENT && line_status == LINE_OK) || ((line_status = parse_line()) == LINE_OK))  {
        text = get_line();
        switch (m_check_state) {
             // 分析请求行
            case CHECK_STATE_REQUESTLINE:
            {
                ret = parse_request_line(text);
                ...
            }
            // 分析头部字段
            case CHECK_STATE_HEADER:
            {
                ret = parse_headers(text);
                ...
                //完整解析GET请求后,跳转到报文响应函数
                else if (ret == GET_REQUEST)
                {
                    return do_request();
                }
            }
            // 解析消息体,只对 POST 
            case CHECK_STATE_CONTENT:
            {
                ret = parse_content(text);
                //完整解析POST请求后,跳转到报文响应函数
                if (ret == GET_REQUEST)
                    return do_request();
                //解析完消息体即完成报文解析,避免再次进入循环,更新line_status
                line_status = LINE_OPEN;
                break;
            }
        }
    }
}

三种状态,标识解析位置:

  • CHECK_STATE_REQUESTLINE:解析请求行
  • CHECK_STATE_HEADER:解析请求头
  • CHECK_STATE_CONTENT:解析消息体,仅用于解析POST请求

处理子线程 处理生成http响应页面逻辑【响应页面映射至内存缓冲区】

从状态机的处理生成响应页面函数 do_request() 逻辑代码:

http_conn::HTTP_CODE http_conn::do_request() {
    const char *p = strrchr(m_url, '/');
    //如果是 POST请求【2表示登录,3 表示注册】,则前面会将 cgi 设置为 1
    if (cgi == 1 && (*(p + 1) == '2' || *(p + 1) == '3')) {
      ...
    }
    // 0 表示跳转注册界面
    else if (*(p + 1) == '0'){
        char *m_url_real = (char *)malloc(sizeof(char) * 200);
        strcpy(m_url_real, "/register.html");
        strncpy(m_real_file + len, m_url_real, strlen(m_url_real));
        free(m_url_real);
    }
    // 1 表示跳转登录界面
    else if (*(p + 1) == '1') {
        ...
        strcpy(m_url_real, "/log.html");
        ...
    }
    // 5 表示 post 图片请求
    else if (*(p + 1) == '5') {
       ...
     }
    // 6 表示 post 视频请求
    else if (*(p + 1) == '6') {
       ...
     }
    // 7 表示跳转关注界面
     else if (*(p + 1) == '6') {
       ...
     }
    //  跳转到欢迎界面
    else 
      ...

    // success : 0; error : -1   若界面资源不存在
    if (stat(m_real_file, &m_file_stat) < 0)
        return NO_RESOURCE;
    // S_IROTH : Read permission for users other than the file owner.
    // 若请求的文件不可 Get  若界面资源不可访问
    if (!(m_file_stat.st_mode & S_IROTH))
        return FORBIDDEN_REQUEST;
    // 若请求的文件是目录则不可 Get,返回报文有误
    if (S_ISDIR(m_file_stat.st_mode))
        return BAD_REQUEST;
    // 只有在请求资源在
    int fd = open(m_real_file, O_RDONLY);
    // 将界面内容映射到内存
    m_file_address = (char *)mmap(0, m_file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);
    return FILE_REQUEST;
}

处理子线程 处理生成http响应报文逻辑 【响应报文写就绪(位于内存)】

根据 do_request() 的返回状态,服务器子线程调用 process_write() 向 m_write_buf 中写入响应报文。

从状态机处理生成http响应报文 process_write() 逻辑代码:

bool http_conn::process_write(HTTP_CODE ret) {
      switch (ret)
      {
            // 内部错误 500
            case INTERNAL_ERROR:
            {
                // 添加状态行,如:HTTP/1.1 500 ERROR
                add_status_line(404, error_404_title);
                // 添加消息报头:包括文本长度、连接状态、空行,如:Connection: keep-alive;   Content-Length: 360
                add_headers(strlen(error_404_form));
                // 添加响应信息,如:here was an unusual problem serving the request file.
                if (!add_content(error_404_form))
                    return false;
                break;
            }
            // 报文语法有误 404
            case BAD_REQUEST:
            {
                // 添加状态行,如:HTTP/1.1 404 ERROR
                // 添加消息报头:包括文本长度、连接状态、空行,如:Connection: keep-alive;   Content-Length: 360
                //  添加响应信息,如:here was an unusual problem serving the request file.
            }
            // 资源没有访问权限, 403
            case FORBIDDEN_REQUEST:
            {
                // 添加状态行,如:HTTP/1.1 404 ERROR
                // 添加消息报头:包括文本长度、连接状态、空行,如:Connection: keep-alive;   Content-Length: 360
                // 添加响应信息,如:here was an unusual problem serving the request file.
            }
            // 文件存在,200
            case FILE_REQUEST:
            {
                // 添加状态行
                add_status_line(200, ok_200_title);   // 将状态信息 “HTTP/1.1  200 OK” 放入缓冲区 m_write_buf
                // 如果请求的资源不为空
                if (m_file_stat.st_size != 0)
                {
                    // 添加响应报头:包括文本长度、连接状态、空行
                    add_headers(m_file_stat.st_size);   // 文本长度为 html 的长度
                    // 第一个 iovec 指针指向响应报文缓冲区,长度指向m_write_idx
                    m_iv[0].iov_base = m_write_buf;  // 字符串为:HTTP/1.1  200 OK
                    m_iv[0].iov_len = m_write_idx;
                    // 第二个 iovec 指针指向 mmap 返回的文件指针,长度指向文件大小
                    m_iv[1].iov_base = m_file_address;
                    m_iv[1].iov_len = m_file_stat.st_size;
                    m_iv_count = 2;
                    // 发送的全部数据为响应报文头部信息和文件大小
                    bytes_to_send = m_write_idx + m_file_stat.st_size;
                    return true;
                }
                // 否则,只返回空白 html 文件
                else
                {
                    const char *ok_string = "<html><body></body></html>";
                    add_headers(strlen(ok_string));
                    if (!add_content(ok_string))
                        return false;
                }
            }
       }
}

struct iovec
为了提高从磁盘读取数据到内存的效率,引入了IO向量机制,IO向量即struct iovec。io 向量用于readv()与writev()。readv【散布读(scatter read)】和writev【聚集写(gather write)】函数用于在一次函数调用中读、写 多个非连续缓冲区

处理子线程 向缓冲区写http响应报文逻辑

子线程调用process_write() 完成响应报文,随后注册 epollout 事件【等待写就绪信号】。服务器主线程检测写事件,并调用http_conn::write() 函数将响应报文发送给浏览器端。if (events[i].events & EPOLLOUT) { users[sockfd].write(); }
write() 函数的逻辑如下:

  • 通过聚集写 writev() 将数据写进套接字缓冲区。但是未必能刚好写满。
  • 若聚集写无法执行,判断是否为缓冲区满。若缓冲区满(erro == EAGAIN),则重新注册套接字的可写事件,然后主线程会再一次检测到写事件,又一次调用 write() ;
    若不是缓冲区满而导致的错误,取消 mmap映射,关闭连接。
  • 若聚集写能写,判断是否一次性写完,还是只写了部分【TCP 连接是面向字节流的】。若一次性写完,还要判断是否为长连接,若是长连接,重新注册读事件,不关闭连接;否则,关闭连接【return false; 然后主线程检测到返回值 fasle,会执行关闭连接实例timer->cb_func(&users_timer[sockfd]); if (timer) { timer_lst.del_timer(timer);}】;若不能一次性写完,则修改 io 向量相关信息,会由于 while(1) 再次将剩余的数据写入套接字缓冲区。
bool http_conn::write() {
    int temp = 0;
    while (1)
    {
        // 将响应报文的状态行、消息头、空行和响应正文发送给浏览器
        temp = writev(m_sockfd, m_iv, m_iv_count);
        // temp 为发送的字节数
        if (temp < 0)
        {
            //如果写阻塞,说明缓冲区满
            if (errno == EAGAIN)
            {
                // 重新注册写事件
                modfd(m_epollfd, m_sockfd, EPOLLOUT);
                return true;
            }
            // 如果发送失败,但不是缓冲区问题,取消映射
            unmap();
            return false;
        }
        // 更新发送字节记录信息
        bytes_have_send += temp;
        bytes_to_send -= temp;
        // 第一个 iovec 头部信息的数据已发送完,发送第 2 个iovec 数据
        if (bytes_have_send >= m_iv[0].iov_len)
        {
            // 不再发送头部信息
            m_iv[0].iov_len = 0;
            m_iv[1].iov_base = m_file_address + (bytes_have_send - m_write_idx);
            m_iv[1].iov_len = bytes_to_send;
        }
        // 否则继续发送第一个 iovec 头部信息的数据
        else
        {
            m_iv[0].iov_base = m_write_buf + bytes_have_send;
            m_iv[0].iov_len = m_iv[0].iov_len - bytes_have_send;
        }
        // 判断条件,数据已全部发送完
        if (bytes_to_send <= 0)
        {
            unmap();
            // 在 epoll 树上重置 EPOLLONSHOT 事件
            modfd(m_epollfd, m_sockfd, EPOLLIN);
            // 若浏览器与服务器是长连接
            if (m_linger)
            {
                // 重新初始化 HTTP 对象
                init();
                return true;
            }
            else
            {
                return false;
            }
        }
     }

}

定时器处理非活动连接模块

统一事件源

统一事件源,指将信号事件与其他事件一样被处理。具体的,信号处理函数将信号(往管道的写端写入信号量)传递给主循环函数,主循环函数(从管道的读端)读出该信号值,并使用 I/O 复用系统调用来监听(管道读端的)可读事件if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)),这样信号事件与其他文件描述符都可以通过 epoll 来监测,从而实现统一处理。

信号是一种异步事件:信号处理函数和程序的主循环是 2 条不同的执行路线。为避免信号 竞态现象(本该到达并响应的信号并未响应),信号处理期间不会再次触发它。因此为确保信号不被屏蔽太久,当信号处理函数被触发时,它只是简单地通知主循环程序接收到信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。

代码解析:

//创建管道
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
// 设置写端非阻塞:若不设置非阻塞,则若缓冲区满导致阻塞,会进一步增加信号处理函数的执行时间
setnonblocking(pipefd[1]);
// 设置读端为 ET 非阻塞
addfd(epollfd, pipefd[0], false);
// 设置 SIGALRM,时间到了会触发
addsig(SIGALRM, sig_handler, false);
// 设置 SIGTERM,kill 会触发
addsig(SIGTERM, sig_handler, false);
bool stop_server = false;
bool timeout = false;
// 每隔 TIMESLOT 时间触发 SIGALRM 信号
alarm(TIMESLOT);
while (!stop_server) {
      // 监测注册到 epollfd 事件表的事件,并将已经发生的事件复制到 events 中
      int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
      for (int i = 0; i < number; i++) {
            int sockfd = events[i].data.fd;
            if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) {
                  int sig;
                char signals[1024];  // buffer
                // 将数据从管道读到缓冲区,如果读端无数据,则返回 -1
                ret = recv(pipefd[0], signals, sizeof(signals), 0);
                if (ret == -1)
                {
                    continue;
                }
                else if (ret == 0)  // 一般不会为 0
                {
                    continue;
                }
                else // 正常情况下返回所读数据的长度,此处总是 1
                {
                    for (int i = 0; i < ret; ++i)
                    {
                        switch (signals[i])
                        {
                            case SIGALRM:
                            {
                                timeout = true;
                                break;
                            }
                            case SIGTERM:
                            {
                                stop_server = true;
                            }
                        }
                    }
                }                  
            }
      }

}

信号处理机制

参考链接:https://zhuanlan.zhihu.com/p/410932421
信号是异步的 。信号的接收是由内核代理,内核接收到信号后,会将其放在进程的信号队列。当进程陷入内核时【陷入内核的方式包括:系统调用、中断、异常。本项目是调用系统调用epoll_wait() 陷入内核】,会检查信号队列,根据相应的信号调取相应的信号处理函数【本项目是仅将信号值从管道写端写入void sig_handler(int sig) { send(pipefd[1], (char *)&msg, 1, 0); }】。由于信号处理函数处于用户态,因此调取信号处理函数实际是从内核态切换到用户态。

定时器容器设计

此处只讨论 adjust_timer() 函数,当定时任务发生变化,调整对应定时器在链表中的位置

  • 客户端在设定时间内有数据收发,则当前时刻对该定时器重新设定时间
  • 被调整的目标定时器在尾部,或定时器新的超时值仍然小于下一个定时器的超时,不用调整
  • 否则先将超时的定时器从链表中删除

定时任务处理函数

使用统一事件源,SIGALRM 信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。

//定时处理任务,重新定时以不断触发 SIGALRM 信号
void timer_handler()
{
   timer_lst.tick();
   alarm(TIMESLOT);
}

while (1) {
    switch (signals[i])
    {
        case SIGALRM:
        {
            timeout = true;
        }
    }
}
...
if (timeout)
{
    timer_handler();
    timeout = false;
}

具体逻辑为:

  • 遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器;
  • 若当前时间小于定时器超时时间,跳出循环;
  • 若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表中删除,继续遍历;

如何使用定时器

  • 浏览器与服务器连接时,创建该连接对应的定时器,并将该定时器添加到链表上;
  • 处理异常事件时,执行定时事件,服务器关闭连接,从链表上移除对应定时器;
  • 处理定时信号时,将定时标志设置为 true timeout = true;
  • 处理读事件时,若某连接上发生读事件,将对应定时器向后移动,否则执行定时事件;
  • 处理写事件时,若服务器通过某连接给浏览器发送数据,将对应定时器向后移动,否则执行定时事件

代码解析:

while (!stop_server) {
    ...
    // 轮询文件描述符
    for (int i = 0; i < number; i++) {
        //处理新到的客户连接
        if (sockfd == listenfd) {
            ...
            // 创建定时器临时变量
            util_timer *timer = new util_timer;
            // 设置定时器对应的连接资源
            timer->user_data = &users_timer[connfd];
            timer->cb_func = cb_func;
            time_t cur = time(NULL);
            timer->expire = cur + 3 * TIMESLOT;
            users_timer[connfd].timer = timer;
            timer_lst.add_timer(timer);
        }
        // 处理异常事件
        else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))  {
            //服务器端关闭连接,移除对应的定时器
            util_timer *timer = users_timer[sockfd].timer;
            timer->cb_func(&users_timer[sockfd]);
            if (timer)
            {
                timer_lst.del_timer(timer);
            }
        }
        //处理信号
        else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) {
          ...
          timeout = true;
        }
        //处理客户连接上接收到的数据
        else if (events[i].events & EPOLLIN) {
            // 创建定时器临时变量,将该连接对应的定时器取出来
            util_timer *timer = users_timer[sockfd].timer;
            ...
            //若有数据传输,则将定时器往后延迟3个单位
            //并对新的定时器在链表上的位置进行调整
            if (timer)
            {
                time_t cur = time(NULL);
                timer->expire = cur + 3 * TIMESLOT;
                timer_lst.adjust_timer(timer);
            }
        }
        else if (events[i].events & EPOLLOUT) {
            util_timer *timer = users_timer[sockfd].timer;
            if (users[sockfd].write()) {
                    //若有数据传输,则将定时器往后延迟3个单位
                    //并对新的定时器在链表上的位置进行调整
                    if (timer)
                    {
                        time_t cur = time(NULL);
                        timer->expire = cur + 3 * TIMESLOT;
                        LOG_INFO("%s", "adjust timer once");
                        Log::get_instance()->flush();
                        timer_lst.adjust_timer(timer);
                    }
            }
            else
            {
                timer->cb_func(&users_timer[sockfd]);
                if (timer)
                {
                    timer_lst.del_timer(timer);
                }
            }
        }
    }
    if (timeout)
    {
        timer_handler();
        timeout = false;
    }
}

日志系统

单例模式

Q:你是如何理解单例模式的?
A:单例模式中类的构造函数与析构函数都是 private 私有类型。它对外提供获取单例的接口,可以提供释放单例内存的接口,其内存也可以由操作系统释放。
单例模式包括饿汉式实现以及懒汉式实现。懒汉式实现一个比较常见的写法是双检测锁机制。

单例模式之经典线程安全懒汉模式

经典线程安全懒汉模式,使用 双检测锁模式
2 次检测 if (NULL == p)的目的不相同:

  • 第 1 次检测:若已经初始化,则无需加解锁,影响性能。【若无第 1 次检测,则每次调用 getinstance 都要加解锁,影响性能】
  • 第 2 次检测:防止在多线程下,有多个线程阻塞与互斥锁,一旦互斥锁解锁,导致其它线程也 new 出一个单例
class single {
private:
    // 私有静态指针变量指向唯一实例
    static single *p;
    // 互斥锁,需要设置为静态锁,因为静态函数只能访问静态成员
    static pthread_mutex_t lock;
    // 私有化构造函数
    single () {
        pthread_mutex_init(&lock, NULL);
    }
    ~single() {}

public:
    // 公有静态方法获取实例
    static single* getinstance();
}

// 类外初始化互斥锁
pthread_mutex_t single::lock;
// 类外初始化静态变量指针
single* single::p = NULL;
single* single::getinstance() {
    if (NULL == p) {  // ① 第一次检测作用: 若已经初始化,则无需加解锁,影响性能
        // 锁上互斥锁,防止有多个线程同时初始化单例对象
        pthread_mutex_lock(&lock);
        // ② 第二次检测作用:防止在多线程下,有多个线程阻塞与互斥锁,一旦互斥锁解锁,导致其它线程也 new 出一个单例
        if (NULL = p) {   // 如果是第一次调用该类函数,则初始化单例
            p = new single();   
        }
        pthread_mutex_unlock(&lock);
    }
    return p;
}
单例模式之使用静态局部变量的线程安全懒汉模式

使用静态局部变量的线程安全懒汉模式之所以不需要检测单例对象是否存在的原因是 static 静态对象一旦初始化就位于 Data 区,不会重复 new 出对象。

class single {
private:
    single() {}
    ~single() {}

public:
  static single* getinstance();
};

single* single::getinstance() {
    pthread_mutex_lock(&lock);
    static single obj;
    pthread_mutex_unlock(&lock);
    return &obj;
}
补充:饿汉模式

饿汉模式无需加锁,因为在程序一运行就产生一个对象。线程调用 getinstance() 只是返回这个对象的地址而已。

class single {
private:
    static single* p;
     single() {}
    ~single() {}
public:
    static single* getinstance();
}
single* single::p = new single();
single* single::getinstance() {
    return p;
}

条件变量 【面试常考】

pthread_cond_wait函数

pthread_cond_wait函数用于等待目标条件变量.该函数调用时需要传入 mutex参数(加锁的互斥锁) ,函数执行时,先把调用线程放入条件变量的请求队列,然后将互斥锁mutex解锁,当函数成功返回为0时,互斥锁会再次被锁上. 也就是说函数内部会有一次解锁和加锁操作。

pthread_mutex_lock(&mutex);
while (线程执行的条件是否成立) {
    pthread_cond_wait(&cond, &mutex);
}   
...
pthread_mutex_unlock(&mutex);

demo实例:

pthread_mutex_lock(&qlock);
//这里需要用while,而不是if
while (workq == NULL) {
  pthread_cond_wait(&qread, &qlock);
}
mq = workq;
workq = mp->m_next;
pthread_mutex_unlock(&qlock);
Q1:pthread_cond_wait 函数使用前为什么要对互斥锁加锁?

因为一旦线程获得了条件变量,就要访问公有资源。在多线程编程下,为了避免资源竞争,所以要加锁。

Q2:pthread_cond_wait 函数内部为什么要解锁?

如果线程无法获得条件变量,就会陷入阻塞态。如果未解锁,其他线程无法访问公有资源。

Q3:为什么要把调用线程放入条件变量的请求队列后再解锁?

在多线程并发环境下,如果当前线程 A 先解锁,可能会导致其他线程 B 获得该互斥锁,进而获得条件变量,然后访问公有资源。这时候线程 A 所等待的条件改变了,但是它并未被放在等待队列上,导致 A 忽略了等待条件被满足的信号。

Q4:为什么最后还要加锁?

在多线程编程下,为了避免资源竞争,所以要加锁。

Q5:为什么判断线程执行的条件是 while 而不是 if?

在多线程并发环境下,可能线程 A、B 同时获得了条件变量,但 B 执行得更快一点,资源(如打印机)被 B 拿到手了,此时线程 A 仍需要继续等待,直到 B 释放打印机资源。

生产者—消费者模型

模型实例:

void process_msg() {
  struct msg* mp;
  for (;;) {
    pthread_mutex_lock(&qlock);
    //这里需要用while,而不是if
    while (workq == NULL) {
      pthread_cond_wait(&qread, &qlock);
    }
    mq = workq;
    workq = mp->m_next;
    pthread_mutex_unlock(&qlock);
    /* now process the message mp */
  }
}

void enqueue_msg(struct msg* mp) {
    pthread_mutex_lock(&qlock);
    mp->m_next = workq;
    workq = mp;
    pthread_mutex_unlock(&qlock);
    /** 此时另外一个线程在signal之前,执行了process_msg,刚好把mp元素拿走*/
    pthread_cond_signal(&qready);
    /** 此时执行signal, 在pthread_cond_wait等待的线程被唤醒,
        但是mp元素已经被另外一个线程拿走,所以,workq还是NULL ,因此需要继续等待*/
}

请书写一个阻塞队列:

class blockQueue {
private:
  int _capacity;  // 队列长度
  queue<int> _queue;   // 队列容器
  pthread_mutex_t _mutex;  // 互斥量
  pthread_cond_t producer_cond;  // 生产者条件变量
  pthread_cond_t consumer_cond;  // 消费者条件变量
public:
  blockQueue(int cap=MAX_QUEUE) : _capacity(cap) {
    pthread_mutex_init(&_mutex, NULL);  // 初始化互斥锁
    pthread_cond_init(&producer_cond, NULL);  // 初始化生产者条件变量
    pthread_cond_init(&consumer_cond, NULL);  // 初始化消费者条件变量
  }
  ~blockQueue()
{
    pthread_mutex_destroy(&_mutex);   // 释放互斥锁
    pthread_mutex_destroy(&producer_cond);   // 释放生产者条件变量
    pthread_mutex_destroy(&consumer_cond);   // 释放消费者条件变量
  }
  bool Push(int data) {
    pthread_mutex_lock(&mutex);  // 获取队列的互斥锁,防止资源争夺
    // 若获取了条件变量,需要再等待获取互斥锁
    // 在高并发环境下,等待这个互斥锁的过程,可能互斥锁又被其他生产者获取,导致队列满
    // 然后本生产者线程获取互斥锁后,依然无法 Push
    // 若无获取到条件变量,会先释放互斥锁
    while (_capacity == queue.size()) {
      pthread_cond_wait(&producer_cond, &_mutex);
    }
    _queue.push(data);
    // 放完物品后,通过 signal 信号通知消费者获取
    pthread_cond_signal(&consumer_cond);  
    pthread_mutex_unlock(&_mutex);
    return true;
  }
  bool Pop(int data) {
    pthread_mutex_lock(&_mutex);
    while (_queue.empty()) {
      pthread_cond_wait(&consumer_cond, &_mutex);
    }
    _queue.pop(data);
    pthread_mutex_signal(&producer_cond, &_mutex);
    pthread_mutex_unlock(&mutex);
    return true;
  }
};

使用 std 版本的代码

#include <thread>
#include <iostream>
#include <queue>
using namespace std;

class Queue {
public:
    queue<int> mq;
    mutex mutex_;  //  互斥锁
    condition_variable cond_;  // 条件变量
    int maxnum; // 设置队列的长度
    
    /*
     * 此用例通过判断 mq 的长度是否满,免去 Cond_Full
     */

public:
    Queue(int maxn = 20) : maxnum(maxn) {}
    bool Push(int val) {
        if (mq.size() >= maxnum) {
            cond_.notify_all();  // 通知消费者读取
            return false;
        }
        // unique_lock RAII 机制的互斥锁
        unique_lock<mutex> locker(mutex_);  // 使用局部锁
        mq.push(val);
        cond_.notify_all();  // V() 使用结束,通知消费者获取
        return true;
    }
    int Pop() {
        unique_lock<mutex> locker(mutex_);
        while (mq.empty())
            cond_.wait(locker);
        int val = mq.front();
        mq.pop();
        cond_.notify_all();
        return val;
    }
};

void Producer(Queue *q) {
    for (int i=0; i<1000; ++i) {
        if (q->Push(i))
            cout << "Push:" << i << endl;
        else
            cout << "Push failed!! " << endl;
        this_thread::sleep_for(chrono::seconds(1));
    }
}

void Consumer(Queue *q) {
    for (int i=0; i<500; ++i) {
        auto data = q->Pop();
        cout << "Pop:" << data << endl;
        this_thread::sleep_for(chrono::seconds(1));
    }
}

int main() {
    Queue q(10);
    thread t1(Producer, &q);
    thread t2(Consumer, &q);
    t1.join();
    t2.join();
    return 0;
}



在优化线程池角度,可以如何

数据库连接池

连接池的功能主要有:初始化、获取连接、释放连接、销毁连接池

初始化

销毁连接池并没有直接被外部调用,而是通过 RAII 机制来完成自动释放;使用信号量实现多线程争夺连接的同步机制,这里将信号量初始化为数据库的连接总数。

// RAII 机制销毁连接池
connection_pool::~connection_pool() {
    DestroyPool();
}

// 构造初始化
void connection_pool::init(string url, string User, string PassWord, string DBname, int Port, unsiged int MaxConn) {
...
for (int i = 0; i < MaxConn; i++)	
{
    MYSQL *con = NULL;
    // 获得 Mysql 句柄
    con = mysql_init(con);   
    // 根据句柄获得一个 Mysql 连接
    con = mysql_real_connect(con, url.c_str(), User.c_str(), PassWord.c_str(), DBName.c_str(), Port, NULL, 0);  
    connList.push_back(con);
    ++FreeConn;
} 
// 将信号初始化为最大连接次数
reserve = sem(FreeConn);
}

RAII 机制释放数据库连接

不直接调用获取和释放连接的接口,将其封装起来,通过 RAII 机制进行获取和释放。

connectionRAII::connectionRAII(MYSQL** SQL, connection_pool *connPool) {
    *SQL = connPool->GetConnection();   // SQL 的值是一个指针的地址
    connRAII = *SQL;  // 将指针的地址赋给一个指针,相当于将指向 conn 实例的指针赋给 connRAII
    poolRAII = connPool;
}

如何解决大文件传输?
1)数据压缩
2)分块传输编码
分块传输需要在响应头配置 Transfer-Encoding 字段
3)多线程并行传输

posted @ 2022-06-01 22:22  MasterBean  阅读(414)  评论(0)    收藏  举报