Linux高性能服务器编程 学习笔记 第十五章 进程池和线程池

动态创建子进程或子线程的缺点:
1.动态创建进程或线程比较耗时,这将导致较慢的客户响应。

2.动态创建的子进程或子线程通常只用来为一个客户服务(除非我们做特殊处理),这将导致系统上产生大量的进程或线程,进程或线程间的切换将消耗大量CPU时间。

3.动态创建的子进程是当前进程的完整映像,当前进程必须谨慎地管理其分配的文件描述符和堆内存等系统资源,否则子进程会复制这些资源,从而使系统的可用资源急剧下降,进而影响服务器性能。

进程池和线程池相似,我们以进程池为例进行介绍,下面对进程池的讨论也适用于线程池。

进程池是由服务器预先创建的一组子进程。线程池中的线程数量应该和CPU数量差不多,防止高负载下有CPU核心未被使用。

进程池中的所有子进程都运行着相同的代码,并具有相同的属性,如优先级、PGID等,因为进程池在服务器启动之初时就创建好了,所以每个子进程都相对干净,即它们没有打开不必要的文件描述符(从父进程继承而来),也不会错误地使用大块的堆内存(从父进程复制得到)。

当有新任务与到来时,主进程将通过某种方式选择进程池中某一子进程来为之服务,相比动态创建子进程,选择一个已经存在的子进程的代价小很多,主进程选择哪个子进程来为新任务服务主要有两种方式:
1.主进程使用某种算法主动选择子进程。最简单、最常用的算法是随机算法和Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作进程中更均匀地分配,从而减轻服务器的整体压力。

2.主进程和所有子进程通过一个共享的工作队列来同步,子进程都睡眠在该工作队列上,当有新任务到来时,主进程将任务添加到工作队列中,这将唤醒正在等待任务的子进程,但只有一个子进程能获得新任务的接管权,它可以从工作队列中取出任务并执行之,而其他子进程将继续睡眠在工作队列上。

选好子进程后,主进程还需使用某种通知机制来告诉目标子进程有新任务需要处理,并传递必要的数据,最简单的方法是,在父进程和子进程之间先创建好一条管道,然后通过该管道来实现所有的进程间通信(当然要预先定义好一套协议来规范管道的使用)。在父线程和子线程之间传递数据就要简单得多,因为我们可以把这些数据定义为全局的,那么它们本身就是被所有线程共享的。

进程池的一般模型为:
在这里插入图片描述
使用进程池处理多客户任务时,首先要考虑的一个问题是:监听socket和连接socket是否都由主进程来统一管理。第8章中,半同步/半反应堆模式是由主进程统一管理这两种socket的,而更高效的半同步/半异步模式和领导者/追随者模式,则是由主进程管理所有监听socket,而各个子进程分别管理属于自己的连接socket的。半同步/半异步模式中,主进程接受新的连接以得到连接socket,然后它需要将该socket传递给子进程(对于线程池而言,父线程将socket传递给子线程是简单的,因为它们可以共享该socket,而对于进程池,我们需要用UNIX域套接字来传递socket);而领导者/追随者模式的灵活性更大一点,因为子进程可以自己调用accept来接受新连接,这样父进程就无须向子进程传递socket,而只需简单地向子进程通知一声:我检测到了新连接,你来接受它。

长连接只一个客户的多次请求可以复用一个TCP连接,在设计进程池时还要考虑:一个客户连接上的所有任务是否始终由一个子进程来处理,如果客户任务是无状态的,那么我们可以考虑使用不同的子进程来为该客户的不同请求服务,如下图所示:
在这里插入图片描述
但如果客户任务是存在上下文关系的,则最好一直用同一个子进程来为之服务,否则实现起来会比较麻烦,我们将不得不在各子进程之间传递上下文数据。

第八章中的半同步/半异步并发模式:
在这里插入图片描述
以下代码实现一个半同步/半异步并发模式的进程池,为了避免在父子进程间传递文件描述符,我们将接受新连接的操作放到子进程中,对于这种模式而言,一个客户连接上的所有任务始终是由一个子进程来处理的:

// filename: processpool.h
#ifndef PROCESSPOOL_H
#define PROCESSPOOL_H

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/stat.h>

// 描述一个子进程的类
class process {
public:
    process() : m_pid(-1) { }

private:
    // 目标子进程的PID
    pid_t m_pid;
    // 父进程和子进程通信用的管道
    int m_pipefd[2];
};

// 进程池类,将它定义为模板类是为了代码复用,其模板参数是处理逻辑任务的类
template <typename T> class processpool {
private:
    // 私有构造函数,只能通过后面的create静态方法来创建processpool实例
    processpool(int listenfd, int process_number = 8);

public:
    // 单体模式,以保证进程最多创建一个processpool实例,这是程序正确处理信号的必要条件
    static processpool<T> *create(int listenfd, int process_number = 8) {
        // 此处有bug,默认new失败会抛异常,而非返回空指针
        if (!m_instance) {
            m_instance = new processpool<T>(listenfd, process_number);
        }
        return m_instance;
    } 
    
    ~processpool() {
        delete[] m_sub_process;
    }
    
    // 启动进程池
    void run();
    
private:
    void setup_sig_pipe();
    void run_parent();
    void run_child();

private:
    // 进程池允许的最大子进程数量
    static const int MAX_PROCESS_NUMBER = 16;
    // 每个子进程最多能处理的客户数量
    static const int USER_PER_PROCESS = 65536;
    // epoll最多能处理的事件数
    static const int MAX_EVENT_NUMBER = 10000;
    // 进程池中的进程总数
    int m_process_number;
    // 子进程在池中的序号,从0开始
    int m_idx;
    // 每个进程都有一个epoll内核事件表,用m_epollfd标识
    int m_epollfd;
    // 监听socket
    int m_listenfd;
    // 子进程通过m_stop决定是否停止运行
    int m_stop;
    // 保存所有子进程的描述信息
    process *m_sub_process;
    // 进程池静态实例
    static processpool<T> *m_instance;
};

teamplate<typename T> processpool<T> *processpool<T>::m_instance = NULL;

// 用来处理信号的管道,以实现统一事件源,后面称之为信号管道
static int sig_pipefd[2];

static int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

static void addfd(int epollfd, int fd) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// 从epollfd参数标识的epoll内核事件表中删除fd上的所有注册事件
static void removefd(int epollfd, int fd) {
    epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);
    close(fd);
}

static void sig_handler(int sig) {
    int save_errno = errno;
    int msg = sig;
    // 发送的sig的低位1字节,如果主机字节序是大端字节序,则发送的永远是0
    send(sig_pipefd[1], (char *)&msg, 1, 0);
    errno = save_errno;
}

static void addsig(int sig, void handler(int), bool restart = true) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart) {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

// 进程池的构造函数,参数listenfd是监听socket,它必须在创建进程池前被创建
// 否则子进程无法直接引用它,参数process_number指定进程池中子进程的数量
template<typename T> processpool<T>::processpool(int listenfd, int process_number) 
: m_listenfd(listenfd), m_process_number(process_number), m_idx(-1), m_stop(false) {
    assert((process_number > 0) && (process_number <= MAX_PROCESS_NUMBER));

    // 此处有bug,默认new失败会抛异常,而非返回空指针
    m_sub_process = new process[process_number];
    assert(m_sub_process);
    
    // 创建process_number个子进程,并建立它们和父进程之间的管道
    for (int i = 0; i < process_number; ++i) {
        int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_sub_process[i].m_pipefd);
        assert(ret == 0);

        m_sub_process[i].m_pid = fork();
        assert(m_sub_process[i].m_pid >= 0);
        if (m_sub_process[i].m_pid > 0) {
            close(m_sub_process[i].m_pipefd[1]);
            continue;
        } else {
            close(m_sub_process[i].m_pipefd[0]);
            m_idx = i;
            break;
        }
    }
}

// 统一事件源
template<typename T> void processpool<T>::setup_sig_pipe() {
    // 创建epoll事件监听表
    m_epollfd = epoll_create(5);
    assert(m_epollfd != -1);
    
    // 创建信号管道
    int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, sig_pipefd);
    assert(ret != -1);
    
    setnonblocking(sig_pipefd[1]);
    addfd(m_epollfd, sig_pipefd[0]);
    
    // 设置信号处理函数
    addsig(SIGCHLD, sig_handler);
    addsig(SIGTERM, sig_handler);
    addsig(SIGINT, sig_handler);
    addsig(SIGPIPE, SIG_IGN);
}

// 父进程中m_idx值为-1,子进程中m_idx值大于等于0,我们据此判断要运行的是父进程代码还是子进程代码
template<typename T> void processpool<T>::run() {
    if (m_idx != -1) {
        run_child();
        return;
    }
    run_parent();
}

template<typename T> void processpool<T>::run_child() {
    setup_sig_pipe();
    
    // 每个子进程都通过其在进程池中的序号值m_idx找到与父进程通信的管道
    int pipefd = m_sub_process[m_idx].m_pipefd[1];
    // 子进程需要监听管道文件描述符pipefd,因为父进程将通过它通知子进程accept新连接
    addfd(m_epollfd, pipefd);
    
    epoll_event events[MAX_EVENT_NUMBER];
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    T *users = new T[USER_PER_PROCESS];
    assert(users);
    int number = 0;
    int ret = -1;
    
    while (!m_stop) {
        number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }
        
        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if ((sockfd == pipefd) && (events[i].events & EPOLLIN)) {
                int client = 0;
                ret = recv(sockfd, (char *)&client, sizeof(client), 0);
                if (((ret < 0) && (errno != EAGAIN)) || ret == 0) {
                    continue;
                } else {
                    struct sockaddr_in client_address;
                    socklen_t client_addrlength = sizeof(client_address);
                    int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, 
                                        &client_addrlength);
                    if (connfd < 0) {
                        printf("errno is: %d\n", errno);
                        continue;
                    }
                    addfd(m_epollfd, connfd);
                    // 模板类T必须实现init方法,以初始化一个客户连接,我们直接使用connfd来索引逻辑处理对象(T对象)
                    // 这样效率较高,但比较占用空间(在子进程的堆内存中创建了65535个T对象)
                    users[connfd].init(m_epollfd, connfd, client_address);
                }
            // 处理子进程接收到的信号
            } else if ((sockfd == sigpipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
                if (ret <= 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                        case SIGCHLD:
                            pid_t pid;
                            int stat;
                            while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
                                continue;
                            }    
                            break;
                        
                        case SIGTERM:
                        case SIGINT:
                            m_stop = true;
                            break;
                        
                        default:
                            break;
                        }
                    }
                }
            // 如果是客户发来的请求,则调用逻辑处理对象的process方法处理之
            } else if (events[i].events & EPOLLIN) {
                users[sockfd].process();
            } else {
                continue;
            }
        }
    }
    
    delete[] users;
    users = NULL;
    close(pipefd);
    // 我们将关闭监听描述符的代码注释掉,以提醒读者:应由m_listenfd的创建者来关闭这个文件描述符
    // 即所谓的对象(如文件描述符或一段堆内存)应由创建函数来销毁
    // close(m_listenfd);
    close(m_epollfd);
}

template<typename T> void processpool<T>::run_parent() {
    setup_sig_pipe();
    
    // 父进程监听m_listenfd
    addfd(m_epollfd, m_listenfd);
    
    epoll_event events[MAX_EVENT_NUMBER];
    int sub_process_counter = 0;
    int new_conn = 1;
    int number = 0;
    int ret = -1;
    
    while (!m_stop) {
        number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR)) {
            printf("epoll failure\n");
            break;
        }
        
        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == m_listenfd) {
                // 如果有新连接到来,就用Round Robin方式将其分配给一个子进程处理
                int i = sub_process_counter;
                do {
                    if (m_sub_process[i].m_pid != -1) {
                        break;
                    }
                    i = (i + 1) % m_process_number;
                } while (i != sub_process_counter);
                
                if (m_sub_process[i].m_pid == -1) {
                    m_stop = true;
                    break;
                }
                sub_process_counter = (i + 1) % m_process_number;
                send(m_sub_process[i].m_pipefd[0], (char *)&new_conn, sizeof(new_conn), 0);
                printf("send request to child %d\n", i);
            } else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN)) {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0);
                if (ret <= 0) {
                    continue;
                } else {
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                        case SIGCHLD:
                            pid_t pid;
                            int stat;
                            while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
                                for (int i = 0; i < m_process_number; ++i) {
                                    // 如果进程池中第i个进程退出
                                    if (m_sub_process[i].m_pid == pid) {
                                        printf("child %d join\n", i);
                                        // 关闭与该子进程的通信管道
                                        close(m_sub_process[i].m_pipefd[0]);
                                        // 将该子进程的m_pid设为-1,表示该子进程已退出
                                        m_sub_process[i].m_pid = -1;
                                    }
                                }
                            }
                            
                            // 如果所有子进程都已退出,则父进程也退出
                            m_stop = true;
                            for (int i = 0; i < m_process_number; ++i) {
                                if (m_sub_process[i].m_pid != -1) {
                                    m_stop = false;
                                }
                            }
                            break;
                        }
                        break;
                        
                        case SIGTERM:
                        case SIGINT:
                            // 如果父进程接收到终止信号,就杀死所有子进程,并等待它们全部结束
                            // 通知子进程结束更好的方式是向父子进程之间的通信管道发送特殊数据
                            printf("kill all the child now\n");
                            for (int i = 0; i < m_process_number; ++i) {
                                int pid = m_sub_process[i].m_pid;
                                if (pid != -1) {
                                    kill(pid, SIGTERM);
                                }
                            }
                            break;
                        
                        default:
                            break;             
                        }
                    }
                }
            } else {
                continue;
            }
        }
    }
    
    // 由创建者关闭这个文件描述符
    // close(m_listenfd);   
    close(m_epollfd); 
}

#endif

用以上进程池实现一个并发CGI服务器:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/wait.h>
#include <sys/stat.h>

// 引用进程池
#include "processpool.h"

// 用于处理客户CGI请求的类,它可作为processpool类的模板参数
class cgi_conn {
public:
    cgi_conn() { }
    
    ~cgi_conn() { }
    
    // 初始化客户连接,清空读缓冲区
    void init(int epollfd, int sockfd, const sockaddr_in &client_addr) {
        m_epollfd = epollfd;
        m_sockfd = sockfd;
        m_address = client_addr;
        memset(m_buf, '\0', BUFFER_SIZE);
        m_read_idx = 0;
    }
    
    void process() {
        int idx = 0;
        int ret = -1;
        // 循环读取和分析客户数据
        while (true) {
            idx = m_read_idx;
            ret = recv(m_sockfd, m_buf + idx, BUFFER_SIZE - 1 - idx, 0);
            // 如果读操作发生错误,则关闭客户连接;如果暂时无数据可读,则退出循环
            if (ret < 0) {
                if (errno != EAGAIN) {
                    removefd(m_epollfd, m_sockfd);
                }
                break;
            // 如果对方关闭连接,则服务器也关闭连接
            } else if (ret == 0) {
                removefd(m_epollfd, m_sockfd);
                break;
            } else {
                m_read_idx += ret;
                printf("user content is: %s\n", m_buf);
                // 如果遇到字符\r\n,则开始处理客户请求
                for (; idx < m_read_idx; ++idx) {
                    if ((idx >= 1) && (m_buf[idx - 1] == '\r') && (m_buf[idx] == '\n')) {
                        break;
                    }
                }
                // 如没有遇到\r\n,则需要读取更多数据
                if (idx == m_read_idx) {
                    continue;
                }
                m_buf[idx - 1] = '\0';
                
                char *file_name = m_buf;
                // 判断客户要运行的CGI程序是否存在
                // access函数用于检测file_name参数表示的文件,F_OK表示检测文件是否存在
                if (access(file_name, F_OK) == -1) {
                    removefd(m_epollfd, m_sockfd);
                    break;
                }
                // 创建子进程执行CGI程序
                ret = fork();
                if (ret == -1) {
                    removefd(m_epollfd, m_sockfd);
                    break;
                } else if (ret > 0) {
                    // 父进程只需关闭连接
                    removefd(m_epollfd, m_sockfd);
                    break;
                } else {
                    // 子进程将标准输出重定向到m_sockfd,并执行CGI程序
                    close(STDOUT_FILENO);
                    dup(m_sockfd);
                    execl(m_buf, m_buf, 0);
                    exit(0);
                }
            }
        }
    }

private:
    static const int BUFFER_SIZE = 1024;
    static int m_epollfd;
    int m_sockfd;
    sockaddr_in m_address;
    char m_buf[BUFFER_SIZE];
    // 标记读缓冲中已经读入的客户数据的最后一个字节的下一个位置
};
int cgi::m_epollfd = -1;

int main(int argc, char *argv[]) {
    if (argc <= 2) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    
    processpool<cgi_conn> *pool = processpool<cgi_conn>::create(listenfd);
    if (pool) {
        pool->run();
        delete pool;
    }
    close(listenfd);    // main函数创建了listenfd,就由它来关闭
    return 0;
}

第八章中的半同步/半反应堆并发模式:
在这里插入图片描述
我们接下来实现上图所示的半同步/半反应堆模式的线程池,相比以上进程池的实现,该线程池的通用性要高得多,因为它使用一个工作队列完全解除了主线程和工作线程的耦合关系:主线程往工作队列插入任务,工作线程通过竞争来取得任务并执行它。但要想将该线程池应用到实际服务器程序中,我们必须保证所有客户请求都是无状态的,因为同一个连接上的不同请求可能会由不同的线程处理。

// filename: threadpool.h
#ifndef THREADPOOL_H
#define THREADPOOL_H

#include <list>
#include <cstdio>
#include <exception>
#include <pthread.h>
// 引用第14章中的线程同步机制的包装类
#include "locker.h"

// 线程池类,将它定义为模板类是为了代码复用,模板参数T是任务类
template <typename T> class threadpool {
public:
    // 参数thread_number是线程池中线程的数量,max_requests参数是请求队列中最多允许的、等待处理的请求数量
    threadpool(int thread_number = 8, int max_requests = 10000);
    ~threadpool();
    // 往请求队列中添加任务
    bool append(T *append);

private:
    // 工作线程运行的函数,它不断从工作队列中取出任务并执行之
    static void *worker(void *arg);
    void run();
    
    // 线程池中线程数
    int m_thread_number;
    // 请求队列中允许的最大请求数
    int m_max_requests;
    // 描述线程池的数组,其大小为m_thread_number
    pthread_t *m_threads;
    // 请求队列
    std::list<T *> m_workqueue;
    // 保护请求队列的互斥锁
    locker m_queuelocker;
    // 是否有任务需要处理
    sem m_queuestat;
    // 是否结束线程
    bool m_stop;
};

template <typename T> threadpool<T>::threadpool(int thread_number, int max_requests)
    : m_thread_number(thread_number), m_max_requests(max_requests), m_stop(false), m_threads(NULL) {
    if ((thread_number <= 0) || (max_requests <= 0)) {
        throw std::exception();
    }
    
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    m_threads = new pthread_t[m_thread_number];
    if (!m_threads) {
        throw std::exception();
    }
    
    // 创建thread_number个线程,并将它们都设为脱离线程
    for (int i = 0; i < thread_number; ++i) {
        printf("create the %dth thread\n", i);
        // 第3个参数必须指向一个静态函数,要想在静态函数中使用类的某对象中的成员,只能通过两种方式:
        // 1.通过类的静态对象来调用,如单体模式中,静态函数通过类的全局唯一实例来访问动态成员函数
        // 2.将类的对象作为参数传递给该静态函数,然后在静态函数中使用这个对象,此处就用的这种方式
        // 将线程参数设置为this指针,然后在worker函数中获取该指针
        if (pthread_create(m_threads + i, NULL, worker, this) != 0) {
            delete[] m_threads;
            throw std::exception();
        }
        if (pthread_detach(m_threads[i])) {
            delete[] m_threads;
            throw std::exception();
        }
    }
}

template<typename T> threadpool<T>::~threadpool() {
    delete[] m_threads;
    m_stop = true;
}

template <typename T> bool threadpool<T>::append(T *request) {
    // 操作工作队列前对其加锁,因为所有线程都共享它
    m_queuelocker.lock();
    if (m_workqueue.size() > m_max_requests) {
        m_queuelocker.unlock();
        return false;
    }
    m_workqueue.push_back(request);
    m_queuelocker.unlock();
    m_queuestat.post();
    return true;
}

template<typename T> void *threadpool<T>::worker(void *arg) {
    threadpool *pool = (threadpool *)arg;
    pool->run();
    return pool;
}

template<typename T> void threadpool<T>::run() {
    while (!m_stop) {
        m_queuestat.wait();
        m_queuelocker.lock();
        if (m_workqueue.empty()) {
            m_queuelocker.unlock();
            continue;
        }
        T *request = m_workqueue.front();
        m_workqueue.pop_front();
        m_queuelocker.unlock();
        if (!request) {
            continue;
        }
        request->process();
    }
}

#endif

下面使用以上线程池实现一个并发Web服务器。

首先我们需要准备线程池的模板参数类用来封装对逻辑任务的处理,这个类是http_conn,以下代码是其头文件http_conn.h:

#ifndef HTTPCONNECTION_H
#define HTTPCONNECTION_H

#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdarg.h>
#include <errno.h>
#include "locker.h"

class http_conn {
public:
    // 文件名的最大长度
    static const int FILENAME_LEN = 200;
    // 读缓冲区的大小
    static const int READ_BUFFER_SIZE = 2048;
    // HTTP请求方法,但我们仅支持GET
    enum METHOD {GET = 0, POST, HEAD, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH};
    // 解析客户请求时,主状态机所处的状态
    enum CHECK_STATE {CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT};
    // 服务器处理HTTP请求的结果:
	// NO_REQUEST:请求不完整,需要继续读取客户数据
	// GET_REQUEST:获得了一个完整的客户请求
	// BAD_REQUEST:客户请求有语法错误
	// FORBIDDEN_REQUEST:客户对资源没有足够的访问权限
	// INTERNAL_ERROR:服务器内部错误
	// CLOSED_CONNECTION:客户已经关闭连接
    enum HTTP_CODE {NO_REQUEST, GET_REQUEST, BAD_REQUEST, NO_RESOURCE, FORBIDDEN_REQUEST,
                    FILE_REQUEST, INTERNAL_ERROR, CLOSED_CONNECTION};
    // 行的读取状态
    enum LINE_STATUS {LINE_OK = 0, LINE_BAD, LINE_OPEN};
    
    http_conn();
    ~http_conn();
    
    // 初始化新接受的连接
    void init(int sockfd, const sockaddr_in &addr);
    // 关闭连接
    void close_conn(bool real_close = true);
    // 处理客户请求
    void process();
    // 非阻塞读操作
    bool read();
    // 非阻塞写操作
    bool write();
    
    // 所有socket上的事件都被注册到同一epoll内核事件表中,所以将epoll文件描述符设置为静态的
    static int m_epollfd;
    // 统计用户数量
    static int m_user_count;

private:
    // 初始化连接
    void init();
    // 解析HTTP请求
    HTTP_CODE process_read();
    // 填充HTTP应答
    bool process_write(HTTP_CODE ret);
    
    // 下面一组函数被process_read函数调用以分析HTTP请求
    HTTP_CODE parse_request_line(char *text);
    HTTP_CODE parse_headers(char *text);
    HTTP_CODE parse_content(char *text);
    HTTP_CODE do_request();
    char *get_line() {
        return m_read_buf + m_start_line;
    }
    LINE_STATUS parse_line();
    
    // 下面一组函数被process_write函数调用以填充HTTP应答
    void unmap();
    bool add_response(const char *format, ...);
    bool add_content(const char *content);
    bool add_status_line(int status, const char *title);
    bool add_headers(int content_length);
    bool add_content_length(int content_length);
    bool add_linger();
    bool add_blank_line();
    
    // 该HTTP连接的socket和对方的socket地址
    int m_sockfd;
    sockaddr_in m_address;
    
    // 读缓冲区
    char m_read_buf[READ_BUFFER_SIZE];
    // 标识读缓冲中已经读入的客户数据的最后一个字节的下一个位置
    int m_read_idx;
    // 当前正在分析的字符在读缓冲区中的位置
    int m_checked_idx;
    // 当前正在解析的行的起始位置
    int m_start_line;
    // 写缓冲区
    char m_write_buf[WRITE_BUFFER_SIZE];
    // 写缓冲区中待发送的字节数
    int m_write_idx;
    
    // 主状态机当前所处的状态
    CHECK_STATE m_check_state;
    // 请求方法
    METHOD m_method;
    
    // 客户请求的目标文件的完整路径,其内容等于doc_root+m_url,doc_root是网站根目录
    char m_real_file[FILENAME_LEN];
    // 客户请求的目标文件的文件名
    char *m_url;
    // HTTP版本号,我们仅支持HTTP/1.1
    char *m_version;
    // 主机名
    char *host;
    // HTTP请求的消息体的长度
    int m_content_length;
    // HTTP请求是否要求保持连接
    bool m_linger;
    
    // 客户请求的目标文件被mmap到内存中的起始位置
    char *m_file_address;
    // 目标文件的状态,可通过它判断文件是否存在、是否是目录、是否可读、文件大小等信息
    struct stat m_file_stat;
    // 我们将采用writev函数来执行写操作,所以定义以下成员,其中m_iv_count表示被写内存块的数量
    struct iovec m_iv[2];
    int m_iv_count;
};

#endif

以下是类http_conn的实现文件http_conn.cpp:

#include "http_conn.h"

// 定义HTTP响应的状态信息
const char *ok_200_title = "OK";
const char *error_400_title = "Bad Request";
const char *error_400_form = "Your request has bad syntax or is inherently impossible to satisfy.\n";
const char *error_403_title = "Forbidden";
const char *error_403_form = "You do not have permission to get file from this server.\n";
const char *error_404_title = "Not Found";
const char *error_404_form = "The requested file was not found on this server.\n";
const char *error_500_title = "Internal Error";
const char *error_500_form = "There was an unusual problem serving the requested file.\n";
const char *doc_root = "/var/www/html";

int setnonblocking(int fd) {
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

void addfd(int epollfd, int fd, bool one_shot) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
    if (one_shot) {
        event.events |= EPOLLONESHOT;
    }
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

void removefd(int epollfd, int fd) {
    // epoll_ctl函数的第4个参数是epoll_event类型指针,用于描述与文件描述符fd参数相关的事件以及关联的数据
    // 此处执行删除操作,只需要指定要删除的文件描述符
    epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);
    close(fd);
}

void modfd(int epollfd, int fd, int ev) {
    epoll_event event;
    event.data.fd = fd;
    event.events = ev | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;
    epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);
}

int http_conn::m_user_count = 0;
int http_conn::m_epollfd = -1;

void http_conn::close_conn(bool real_close) {
    // 如果real_close为true且当前连接的socket存在(该连接的socket,即m_sockfd,不为-1)
    if (read_close && (m_sockfd != -1)) {
        removefd(m_epollfd, m_sockfd);
        m_sockfd = -1;
        // 关闭一个连接时,将客户总数减1
        --m_user_count;
    }
}

void http_conn::init(int sockfd, const sockaddr_in &addr) {
    m_sockfd = sockfd;
    m_address = addr;
    // 以下两行是为了避免TIME_WAIT状态,仅用于调试,实际使用时应去掉
    int reuse = 1;
    setsockopt(m_sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    addfd(m_epollfd, sockfd, true);
    ++m_user_count;
    
    init();
}

void http_conn::init() {
    m_check_state = CHECK_STATE_REQUESTLINE;
    m_linger = false;
    
    m_method = GET;
    m_url = 0;
    m_version = 0;
    m_content_length = 0;
    m_host = 0;
    m_start_line = 0;
    m_checked_idx = 0;
    m_read_idx = 0;
    m_write_idx = 0;
    memset(m_read_buf, '\0', READ_BUFFER_SIZE);
    memset(m_write_buf, '\0', WRITE_BUFFER_SIZE);
    memset(m_real_file, '\0', FILENAME_LEN);
}

// 从状态机
http_conn::LINE_STATUS http_conn::parse_line() {
    char temp;
    for (; m_checked_idx < m_read_idx; ++m_checked_idx) {
        temp = m_read_buf[m_checked_idx];
        if (temp == '\r') {
            if ((m_checked_idx + 1) == m_read_idx) {
                return LINE_OPEN;
            } else if (m_read_buf[m_checked_idx + 1] == '\n') {
                m_read_buf[m_checked_idx++] = '\0';
                m_read_buf[m_checked_idx++] = '\0';
                return LINE_OK;
            }
            
            return LINE_BAD;
        } else if (temp == '\n') {
            if ((m_checked_idx > 1) && (m_read_buf[m_checked_idx - 1] == '\r')) {
                m_read_buf[m_checked_idx - 1] = '\0';
                m_read_buf[m_checked_idx++] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }
    }
    
    return LINE_OPEN;
}

bool http_conn::read() {
    if (m_read_idx >= READ_BUFFER_SIZE) {
        return false;
    }
    
    int bytes_read = 0;
    while (true) {
        bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);
        if (bytes_read == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                break;
            }
            return false;
        } else if (bytes_read == 0) {
            return false;
        }
        
        m_read_idx += bytes_read;
    }
    return true;
}

// 解析HTTP请求行,获得请求方法、目标URL、HTTP版本号
// 我们预期text的格式类似GET /path/to/resource HTTP/1.1
http_conn::HTTP_CODE http_conn::parse_request_line(char *text) {
    // strpbrk函数用于在一个字符串中查找第一个包含在指定字符集合中的字符,并返回该字符在字符串中的位置
    m_url = strpbrk(text, " \t");
    if (!m_url) {
        return BAD_REQUEST;
    }
    *m_url++ = '\0';
    
    char *method = text;
    if (strcasecmp(method, "GET") == 0) {
        m_method = GET;
    } else {
        return BAD_REQUEST;
    }
    
    // strspn函数返回一个size_t类型的值,表示在第一个参数中从开头开始的连续字符数量
    // 这些字符都包含在第二个参数中的字符集合中
    m_url += strspn(m_url, " \t");
    m_version = strpbrk(m_url, " \t");
    if (!m_version) {
        return BAD_REQUEST;
    }
    *m_version++ = '\0';
    m_version += strspn(m_version, " \t");
    if (strcasecmp(m_version, "HTTP/1.1") != 0) {
        return BAD_REQUEST;
    }
    
    if (strncasecmp(m_url, "http://", 7) == 0) {
        m_url += 7;
        // strchr函数在一个字符串中查找指定字符的第一次出现的位置,并返回该位置的指针
        m_url = strchr(m_url, '/');
    }
    
    if (!m_url || m_url[0] != '/') {
        return BAD_REQUEST;
    }
    
    m_check_state = CHECK_STATE_HEADER;
    return NO_REQUEST;
}

// 解析HTTP请求的一个头部信息
http_conn::HTTP_CODE http_conn::parse_headers(char *text) {
    // 遇到空行,表示不再有头部字段
    if (text[0] == '\0') {
        // 如果HTTP请求有消息体,则还需读取m_content_length字节的消息体
        // 状态机转移到CHECK_STATE_CONTENT状态
        if (m_content_length != 0) {
            m_check_state = CHECK_STATE_CONTENT;
            return NO_REQUEST;
        }
        
        // 否则说明我们已经得到了一个完整HTTP请求
        return GET_REQUEST;
    } else if (strncasecmp(text, "Connection:", 11) == 0) {
            text += 11;
            text += strspn(text, " \t");
            if (strcasecmp(text, "keep-alive") == 0) {
                m_linger = true;
            }
        }
    } else if (strncasecmp(text, "Content-Length:", 15) == 0) {
        text += 15;
        text += strspn(text, " \t");
        m_content_length = atoi(text);
    } else if (strncasecmp(text, "Host:", 5) == 0) {
        text += 5;
        text += strspn(text, " \t");
        m_host = text;
    } else {
        printf("oop! unknown header %s\n", text);
    }
    
    return NO_REQUEST;
}

// 我们没有真正解析HTTP请求的消息体,只是判断它是否被完整读入了
http_conn::HTTP_CODE http_conn::parse_content(char *text) {
    if (m_read_idx >= m_content_length + m_checked_idx) {
        text[m_content_length] = '\0';
        return GET_REQUEST;
    }
    
    return NO_REQUEST;
}

// 主状态机
http_conn::HTTP_CODE http_conn::process_read() {
    LINE_STATUS line_status = LINE_OK;
    HTTP_CODE ret = NO_REQUEST;
    char *text = 0;
    
    while (((m_check_state == CHECK_STATE_CONTENT) && (line_status == LINE_OK)) 
           || ((line_status = parse_line()) == LINE_OK)) {
        text = get_line();
        m_start_line = m_checked_idx;
        printf("got 1 http line: %s\n", text);
        
        switch (m_check_state) {
        case CHECK_STATE_REQUESTLINE:
            ret = parse_request_line(text);
            if (ret == BAD_REQUEST) {
                return BAD_REQUEST;
            }
            break;
        
        case CHECK_STATE_HEADER:
            ret = parse_headers(text);
            if (ret == BAD_REQUEST) {
                return BAD_REQUEST;
            } else if (ret == GET_REQUEST) {
                return do_request();
            }
            break;
         
        default:
            return INTERNAL_ERROR;   
        }
    }
    
    return NO_REQUEST;
}

// 得到一个完整、正确的HTTP请求时,该函数分析目标文件的属性
// 如果目标文件存在、对所有用户可读、不是目录,则使用mmap函数将其映射到内存地址m_file_address处
// 并告诉调用者获取文件成功
http_conn::HTTP_CODE http_coonn::do_request() {
    strcpy(m_real_file, doc_root);
    int len = strlen(doc_root);
    strncpy(m_real_file + len, m_url, FILENAME_LEN - len - 1);
    if (stat(m_real_file, &m_file_stat) < 0) {
        return NO_RESOURCE;
    }
    
    if (!(m_file_stat.st_mode & S_IROTH)) {
        return FORBIDDEN_REQUEST;
    }
    
    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;
}

// 对内存映射区执行munmap操作
void http_conn::unmap() {
    if (m_file_address) {
        munmap(m_file_address, m_file_stat.st_size);
        m_file_address = 0;
    }
}

// 写HTTP响应
bool http_conn::write() {
    int temp = 0;
    int bytes_have_send = 0;
    int bytes_to_send = m_write_idx;
    if (bytes_to_send == 0) {
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        init();
        return true;
    }
    
    while (1) {
        temp = writev(m_sockfd, m_iv, m_iv_count);
        if (temp <= -1) {
            // 如果TCP写缓冲区没有空间,则等待下一轮EPOLLOUT事件
            // 虽然在此期间,服务器无法立即收到同一客户的下一请求,但这可保证同一连接的完整性
            if (errno == EAGAIN) {
                modfd(m_epollfd, m_sockfd, EPOLLOUT);
                return true;
            }
            unmap();
            return false;
        }
        
        bytes_to_send -= temp;
        bytes_have_send += temp;
        if (bytes_to_send <= bytes_have_send) {
            // 发送HTTP响应成功
            unmap();
            if (m_linger) {
                // 此处处理完一个请求后,直接调用init清空了读缓冲区
                // 如果客户连续发送多个请求,读缓冲区中可能有多于一个请求的数据
                // 会丢失请求,如果读缓冲中某个请求只读了一半,则接下来的读操作会读入另一半
                // 然后由于只有一半请求而被认为是请求语法有问题
                init();
                modfd(m_epollfd, m_sockfd, EPOLLIN);
                return true;
            } else {
                modfd(m_epollfd, m_sockfd, EPOLLIN);
                return false;
            }
        }
    }
}

bool http_conn::add_response(const char *format, ...) {
    if (m_write_idx >= WRITE_BUFFER_SIZE) {
        return false;
    }
    va_list arg_list;
    va_start(arg_list, format);
    // vsnprintf函数会根据format字符串中的格式控制码,将可变参数列表中的值格式化后写入str所指向的缓存区
    // 该函数返回写入缓冲区的字节数,包含结尾的\0
    // 如果缓冲区太小,则该函数返回要写入的数据的字节数(此时不包含结尾的\0)
    // 因此,如果该函数返回值大于等于第二个参数的大小,说明缓冲区太小
    int len = vsnprintf(m_write_buf + m_write_idx, WRITE_BUFFER_SIZE - 1 - m_write_idx, format, arg_list);
    if (len >= (WRITE_BUFFER_SIZE - 1 - m_write_idx)) {
        return false;
    }
    m_write_idx += len;
    // va_start函数和va_end函数必须成对出现,va_end函数用于清理可变参数列表,用于避免潜在的内存泄漏或数据损坏
    va_end(arg_list);
    return true;
}

bool http_conn::add_status_line(int status, const char *title) {
    return add_response("%s %d %s\r\n", "HTTP/1.1", status, title);
}

bool http::conn::add_headers(int content_len) {
    add_content_length(content_len);
    add_linger();
    add_blank_line();
}

bool http_conn::add_content_length(int content_len) {
    return add_response("Content-Length: %d\r\n", content_len);
}

bool http_conn::add_linger() {
    return add_response("Connection: %s\r\n", (m_linger == true) ? "keep-alive" : "close");
}

bool http_conn::add_blank_line() {
    return add_response("%s", "\r\n");
}

bool http_conn::add_content(const char *content) {
    return add_response("%s", content);
}

// 根据服务器处理HTTP请求的结果,决定返回给客户端的内容
bool http_conn::process_write(HTTP_CODE ret) {
    switch (ret) {
    case INTERNAL_ERROR:
        add_status_line(500, error_500_title);
        add_headers(strlen(error_500_form));
        if (!add_content(error_500_form)) {
            return false;
        }
        break;
    
    case BAD_REQUEST:
        add_status_line(400, error_400_title);
        add_headers(strlen(error_400_form));
        if (!add_content(error_400_form)) {
            return false;
        }
        break;
    
    case NO_RESOURCE:
        add_status_line(400, error_400_title);
        add_headers(strlen(error_404_form));
        if (!add_content(error_404_form)) {
            return false;
        }
        break;
       
    case FORBIDDEN_REQUEST:
        add_status_line(403, error_403_title);
        add_headers(strlen(error_403_form));
        if (!add_content(error_403_form)) {
            return false;
        }    
        break;

    case FILE_REQUEST:
        add_status_line(200, ok_200_title);
        if (m_file_stat.st_size != 0) {
            add_headers(m_file_stat.st_size);
            m_iv[0].iov_base = m_write_buf;
            m_iv[0].iov_len = m_write_idx;
            m_iv[1].iov_base = m_file_address;
            m_iv[1].iov_len = m_file_stat.st_size;
            m_iv_count = 2;
            return true;
        } else {
            const char *ok_string = "<html><body></body></html>";
            add_headers(strlen(ok_string));
            if (!add_content(ok_string)) {
                return false;
            }
        }
        break;
    
    default:
        return false;    
    }
    
    m_iv[0].iov_base = m_write_buf;
    m_iv[0].iov_len = m_write_idx;
    m_iv_count = 1;
    return true;
}

// 由线程池中的工作线程调用,这是处理HTTP请求的入口
void http_conn::process() {
    HTTP_CODE read_ret = process_read();
    if (read_ret == NO_REQUEST) {
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        return;
    }
    
    bool write_ret = process_write(read_ret);
    if (!write_ret) {
        close_conn();
    }
    modfd(m_epollfd, m_sockfd, EPOLLOUT);
}

定义好任务类后,main函数只需负责IO读写即可:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <cssert>
#include <sys/epoll.h>
#include <libgen.h>

#include "locker.h"
#include "threadpool.h"
#include "http_conn.h"

#define MAX_FD 65536
#define MAX_EVENT_NUMBER 10000

extern int addfd(int epollfd, int fd, bool one_shot);
extern int removefd(int epollfd, int fd);

void addsig(int sig, void handler(int), bool restart = true) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart) {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

void show_error(int connfd, const char *info) {
    printf("%s", info);
    send(connfd, info, strlen(info), 0);
    close(connfd);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    
    // 忽略SIGPIPE信号
    addsig(SIGPIPE, SIG_IGN);
    
    // 创建线程池
    threadpool<http_conn> *pool = NULL;
    try {
        pool = new threadpool<http_conn>;
    // 捕获所有异常
    } catch ( ... ) {
        return 1;
    }
    
    // 预先为每个可能的客户连接分配一个http_conn对象
    // 此处有bug,默认new失败会抛异常,而非返回空指针
    http_conn *users = new http_conn[MAX_FD];
    assert(users);
    int user_count = 0;
    
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    // 关闭连接时,直接给对面发送RST
    struct linger tmp = {1, 0};
    setsockopt(listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
    
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret >= 0);
    
    ret = listen(listenfd, 5);
    assert(ret >= 0);
    
    epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd, false);
    http_conn:m_epollfd = epollfd;
    
    while (true) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if (number < 0 && errno != EINTR) {
            printf("epoll failure\n");
            break;
        }
        
        for (int i = 0; i < number; ++i) {
            int sockfd = events[i].data.fd;
            if (sockfd == listenfd) {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                if (connfd < 0) {
                    printf("errno is: %d\n", errno);
                    continue;
                }
                if (http_conn::m_user_count >= MAX_FD) {
                    show_error(connfd, "Internal server busy");
                    continue;
                }
                // 初始化客户连接
                users[connfd].init(connfd, client_address);
            }
	        } else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
	            // 如果有异常,直接关闭客户连接
	            users[sockfd].close_conn();
	        } else if (events[i].events & EPOLLIN) {
	            // 根据读的结果,决定是将任务添加到线程池,还是关闭连接
	            if (users[sockfd].read()) {
	                pool->append(users + sockfd);
	            } else {
	                users[sockfd].close_conn();
	            }
	        } else if (events[i].events & EPOLLOUT) {
	            // 根据写的结果,决定是否关闭连接
	            if (!users[sockfd].write()) {
	                users[sockfd].close_conn();
	            }
	        } 
        }
    }
    
    close(epollfd);
    close(listenfd);
    delete[] users;
    delete pool;
    return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值