代码开源:
https://github.com/PetterZhukov/webserver_HTTP
介绍:
webserver_HTTP
使用了线程池,通过epoll实现的Proactor版本的web服务器。参考了游双老师的《Linux高性能服务器编程》以及牛客网的《Linux高并发服务器开发》课程。在自己复现的基础上进行模块的整合并添加一些小更改。所有代码拥有完备的注释。访问的资源在 同级目录"resources"文件夹中
目录
因为关于子线程进行的操作(即process)以及最关键的模块http_conn我还没介绍,因此貌似会有一些缺漏未介绍。
但是——让我很自豪的是,我通过各种修改,使得读写对子线程尽可能透明(当然仍然有作为接口的操作),排除了一些需要了解http_conn模块才能处理的部分。
当然,比如报文需要分多份发送,因此有些变量必须是共享的,所以耦合性不能避免。
值得一提的是,Read和Write之前是http_conn的成员方法,但是被我提出来了,我觉得这样更符合模块设计,也有更好的可读性。
4.1 主线程的读
4.1.1 recv的介绍
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数:
- sockfd: 要发送的fd
- buf:要发送的数据存储的位置
- len:要发送的长度
- flags:通常为0
返回值:
- 成功:返回发送成功的字节数
- 可能为0
- 失败:返回-1
- 结束:返回0
4.1.2 流程介绍
PS:若发生EAGAIN错误会停止
PS2:假如之后进行分析说明本次连接还未结束,则会修改epoll内核的事件信息再次调用Write进行读写
如图,使用recv进行循环读写,直到对方关闭连接/发生错误,因为使用的文件描述符是非阻塞的,所以发生的错误中若有EAGIN(使用写操作同理),说明当前没有数据可读(但是还没断开连接),也是正常情况。
4.1.3 各种边角操作
因为是要与子线程的内存部分进行交互,且对子线程是透明的,因此我是采用手写的get和set方法对属性进行操作。
读内存要修改和使用的值很简单,只有两个,一个是要修改的内存,一个是修改后内存的大小。因此开头加上获取内存和内存大小,然后进行读取后,结尾加上设置内存大小即可(内存因为是指针,直接进行对应的修改,不需要写回)。
4.1.4 读操作的实现
// 非阻塞的读,循环读取
bool epoll_class::Read(http_conn &conn)
{
int m_sockfd=conn.get_sockfd();
int m_read_index=conn.get_read_index();
char *m_read_buf=conn.get_read_buf();
// 缓冲已满
if(m_read_index >= READ_BUFFER_SIZE){
return false;
}
// 读取字节
while(true)
{
int bytes_read=recv(m_sockfd,m_read_buf+m_read_index,READ_BUFFER_SIZE-m_read_index,0);
if(bytes_read==-1){
if(errno==EAGAIN || errno==EWOULDBLOCK)
break;
else
return false;
}
else if(bytes_read==0){
return false;
}
m_read_index+=bytes_read;
}
// 更新值
conn.set_read_index(m_read_index);
return true;
}
PS:假如去看我的master部分的代码(也就是更接近老师的版本的部分),因为Read定义在http_conn中, 因此其不需要开头和结尾的set,get操作,不过我觉得这样的分离是可以接受的。
4.2 主线程的写
4.2.1 send的介绍
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- sockfd:接收方对应的文件描述符
- buf:存储要发送字符的缓冲区
- len:发送的字符的长度
- flags:一般为0
send和write其实很像,之所以要介绍send,是因为相比分散写,send和write更为常用(writev 是在发送多个分散内存的时候有用)。
4.2.2 分散写writev的介绍
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
- 参数:
- fd:接收方的fd
- iov:存储发送数据的数组,每个元素包含起始指针和长度
- iovcnt:iov数组的长度
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
struct iovec的结构,里面有起始位置,以及长度
- 返回值:
- 成功:发送的字节数
- 失败:-1
需要注意的是,在调用writev之后,它不会自动调整iov数组里面的值,因此假如需要多次发送(毕竟发送的数据一个报文不一定装得下)。这时候需要根据返回值来对其进行调整。不过好在writev是按顺序发的,即从第0,1,2,3......这样的顺序发送,因此遍历调整即可。
4.2.3 流程介绍
从刚刚的介绍可以得知,我们的目的是调用writev将几个零散的内存发送给接受方。
通过后面的介绍可以得知,发送的主要是两块(具体是哪两块后面再介绍),且每一块的内容和长度都包含在iov数组中,其内容对Write函数是完全透明的。
我先介绍一下http_conn类对Write的接口,它需要向Write传入以下几个参数
- sockfd:接收方的文件描述符
- bytes_to_send:要发送的总字节数
- m_iv:iovec数组,包含了要发送的内容的信息
- m_iv_count:m_iv数组的长度
以下是发送流程
由上一节的代码可以得知,返回值表示后续操作
- true为不操作
- 失败即关闭文件描述符,有两种情况,写失败,或者写成功且不保持连接
4.2.4 各种边角操作的判断
我在http_conn中实现了clear操作,作用是将类中所有的中间属性全部清零(不包括文件描述符、地址等属性,其在init方法中操作),还有内存映射释放的操作。
发送完成(要发送的字节数为0/已经全部发送),在返回之前需要调用clear进行清除操作。
在返回之前,因为要重新设置epoll事件(假如是oneshot类型,则事件需要重新注册,因此都需要调用modfd函数。
(至于其中的modfd函数的具体组成,详见下节)
采用EPOLLONETSHOT事件的文件描述符上的注册事件只触发一次,要想重新注册事件则需要调用epoll_ctl重置文件描述符上的事件,这样前面的socket就不会出现竞态。
4.2.5 具体实现
// 非阻塞的写
bool epoll_class::Write(http_conn &conn)
{
const int m_sockfd = conn.get_sockfd();
const int bytes_to_send = conn.get_bytes_to_send();
iovec *m_iv = conn.get_iv();
const int m_iv_count = conn.get_iv_count();
if(bytes_to_send==0){
// 将要发送的字节为0
modfd(epollfd,m_sockfd,EPOLLIN);
conn.clear();
return true;
}
while (true)
{
int ret = writev(m_sockfd, m_iv, m_iv_count);
if (ret <= -1)
{
// 发送失败
if (errno == EAGAIN)
{// 重试
modfd(epollfd, m_sockfd, EPOLLOUT);
return true;
}
return false;
}
// 本次写成功
// 维护还需发送字节数和已发送字节数
//分散写第一部分是否写完
if (ret >= m_iv[0].iov_len)
{ // 第一部分写完了
m_iv[1].iov_base = (char *)m_iv[1].iov_base + (ret - m_iv[0].iov_len);
m_iv[1].iov_len -= (ret - m_iv[0].iov_len);
m_iv[0].iov_len = 0;
}
else
{ // 第一部分还没写完
m_iv[0].iov_base = (char *)(m_iv[0].iov_base) + ret;
m_iv[0].iov_len -= ret;
}
// 发送结束
if (m_iv[1].iov_len<=0)
{ // 发送HTTP响应成功,释放内存
modfd(epollfd, m_sockfd, EPOLLIN);
// 是否keep-alive
if (conn.is_keepalive())
{
conn.clear();
// 继续接受信息
return true;
}
else
{
return false;
}
}
}
}