数据结构,很多初学者对它的实际用处了解较少,《Build Your Own Redis with C/C++》讲述了如何从0使用C/C++,运用基本的数据结构,构建属于自己的Redis。前一章“协议解析”我们一起学习了如何构建一个简单的Redis服务器,包括协议解析、IO辅助函数、请求处理。这一章我们一起学习如何构建一个回声服务器,如何建立新连接、进行状态机读写以及协议解析。
原书链接:https://build-your-own.org/redis/
Conn结构体的定义如下:
enum {
STATE_REQ = 0,
STATE_RES = 1,
STATE_END = 2, // mark the connection for deletion
};
struct Conn {
int fd = -1;
uint32_t state = 0; // either STATE_REQ or STATE_RES
// buffer for reading
size_t rbuf_size = 0;
uint8_t rbuf[4 + k_max_msg];
// buffer for writing
size_t wbuf_size = 0;
size_t wbuf_sent = 0;
uint8_t wbuf[4 + k_max_msg];
};
在非阻塞模式下,I/O操作经常被推迟执行,因此我们需要为读写操作准备缓冲区。状态用于决定如何处理连接。对于正在进行的连接,有两种状态。STATE_REQ状态用于读取请求,而STATE_RES状态用于发送响应。
具体来说:
缓冲区的作用:在非阻塞模式下,I/O操作(如读或写)可能不会立即完成,而是被推迟执行。为了处理这种情况,需要使用缓冲区来暂存数据。当读操作被推迟时,可以将数据先读取到缓冲区中;当写操作被推迟时,可以先将数据写入缓冲区,等待后续时机再发送。
状态的作用:通过定义不同的状态,可以决定如何处理连接。文中提到了两种状态:
事件循环代码如下:
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
die("socket()");
}
// bind, listen and etc
// code omitted...
// a map of all client connections, keyed by fd
std::vector<Conn *> fd2conn;
// set the listen fd to nonblocking mode
fd_set_nb(fd);
// the event loop
std::vector<struct pollfd> poll_args;
while (true) {
// prepare the arguments of the poll()
poll_args.clear();
// for convenience, the listening fd is put in the first position
struct pollfd pfd = {fd, POLLIN, 0};
poll_args.push_back(pfd);
// connection fds
for (Conn *conn : fd2conn) {
if (!conn) {
continue;
}
struct pollfd pfd = {};
pfd.fd = conn->fd;
pfd.events = (conn->state == STATE_REQ) ? POLLIN : POLLOUT;
pfd.events = pfd.events | POLLERR;
poll_args.push_back(pfd);
}
// poll for active fds
// the timeout argument doesn't matter here
int rv = poll(poll_args.data(), (nfds_t)poll_args.size(), 1000);
if (rv < 0) {
die("poll");
}
// process active connections
for (size_t i = 1; i < poll_args.size(); ++i) {
if (poll_args[i].revents) {
Conn *conn = fd2conn[poll_args[i].fd];
connection_io(conn);
if (conn->state == STATE_END) {
// client closed normally, or something bad happened.
// destroy this connection
fd2conn[conn->fd] = NULL;
(void)close(conn->fd);
free(conn);
}
}
}
// try to accept a new connection if the listening fd is active
if (poll_args[0].revents) {
(void)accept_new_conn(fd2conn, fd);
}
}
return 0;
}
在这段代码中,事件循环的首要任务是设置poll
函数的参数。监听文件描述符(fd)使用POLLIN
标志进行轮询,这意味着我们正在等待新的连接请求。对于连接文件描述符,struct Conn
的状态决定了poll
标志。在这种情况下,poll
标志要么是用于读取(POLLIN
),要么是用于写入(POLLOUT
),但不会同时是两者。
如果使用epoll
,事件循环中的第一件事通常是使用epoll_ctl
更新文件描述符集合。
poll
函数还接受一个超时参数,这个参数稍后可以用来实现定时器。在这一点上,超时并不重要,所以我们只是设置一个较大的数字。
poll
返回后,我们会得到通知,知道哪些文件描述符准备好了进行读取/写入,然后相应地采取行动。
accept_new_conn()
函数用于接受一个新的连接,并创建一个 Conn
结构体对象。
static void conn_put(std::vector<Conn *> &fd2conn, struct Conn *conn) {
if (fd2conn.size() <= (size_t)conn->fd) {
fd2conn.resize(conn->fd + 1);
}
fd2conn[conn->fd] = conn;
}
static int32_t accept_new_conn(std::vector<Conn *> &fd2conn, int fd) {
// accept
struct sockaddr_in client_addr = {};
socklen_t socklen = sizeof(client_addr);
int connfd = accept(fd, (struct sockaddr *)&client_addr, &socklen);
if (connfd < 0) {
msg("accept() error");
return -1; // error
}
// set the new connection fd to nonblocking mode
fd_set_nb(connfd);
// creating the struct Conn
struct Conn *conn = (struct Conn *)malloc(sizeof(struct Conn));
if (!conn) {
close(connfd);
return -1;
}
conn->fd = connfd;
conn->state = STATE_REQ;
conn->rbuf_size = 0;
conn->wbuf_size = 0;
conn->wbuf_sent = 0;
conn_put(fd2conn, conn);
return 0;
}
connection_io()
是处理客户端连接的有限状态机。在编程中,有限状态机(State Machine)是一种计算模型,它可以根据输入和当前状态来决定下一个状态。
static void connection_io(Conn *conn) {
if (conn->state == STATE_REQ) {
state_req(conn);
} else if (conn->state == STATE_RES) {
state_res(conn);
} else {
assert(0); // not expected
}
}
STATE_REQ状态用于读取:
static void state_req(Conn *conn) {
while (try_fill_buffer(conn)) {}
}
static bool try_fill_buffer(Conn *conn) {
// try to fill the buffer
assert(conn->rbuf_size < sizeof(conn->rbuf));
ssize_t rv = 0;
do {
size_t cap = sizeof(conn->rbuf) - conn->rbuf_size;
rv = read(conn->fd, &conn->rbuf[conn->rbuf_size], cap);
} while (rv < 0 && errno == EINTR);
if (rv < 0 && errno == EAGAIN) {
// got EAGAIN, stop.
return false;
}
if (rv < 0) {
msg("read() error");
conn->state = STATE_END;
return false;
}
if (rv == 0) {
if (conn->rbuf_size > 0) {
msg("unexpected EOF");
} else {
msg("EOF");
}
conn->state = STATE_END;
return false;
}
conn->rbuf_size += (size_t)rv;
assert(conn->rbuf_size <= sizeof(conn->rbuf));
// Try to process requests one by one.
// Why is there a loop? Please read the explanation of "pipelining".
while (try_one_request(conn)) {}
return (conn->state == STATE_REQ);
}
在上述代码片段中,try_fill_buffer()
函数的作用是将数据填充到读缓冲区中。由于读缓冲区的大小是有限的,所以在遇到 EAGAIN
错误之前,缓冲区可能会被填满。因此,我们需要在读取数据后立即处理数据,以便清除一些读缓冲区的空间,然后 try_fill_buffer()
函数会循环执行,直到遇到 EAGAIN
。
EAGAIN
错误表示非阻塞模式下的系统调用(如 read
)因为资源暂时不可用而无法立即完成。在这种情况下,程序应该稍后重试该调用。循环中的 try_fill_buffer()
函数会持续尝试读取数据,直到无法再读取更多数据(即遇到 EAGAIN
)。
此外,读取系统调用(以及任何其他系统调用)在接收到 errno
为 EINTR
的错误时需要重试。EINTR
表示系统调用被信号中断了。即使我们的应用程序不使用信号,也需要重试,因为系统调用可能因为其他原因被中断,例如操作系统调度或其他系统级事件。
try_one_request
函数会放在一个循环中。原因在于,客户端在发送请求时,并不局限于一次只发送一个请求并等待响应。客户端可以通过发送多个请求而不必等待每个请求的响应来节省延迟,这种操作模式被称为“管道化”(pipelining)。因此,我们不能假定读取缓冲区中最多只有一个请求。这意味着读取缓冲区可能包含多个请求,所以需要循环处理,直到缓冲区中没有更多的请求为止。
static bool try_one_request(Conn *conn) {
// try to parse a request from the buffer
if (conn->rbuf_size < 4) {
// not enough data in the buffer. Will retry in the next iteration
return false;
}
uint32_t len = 0;
memcpy(&len, &conn->rbuf[0], 4);
if (len > k_max_msg) {
msg("too long");
conn->state = STATE_END;
return false;
}
if (4 + len > conn->rbuf_size) {
// not enough data in the buffer. Will retry in the next iteration
return false;
}
// got one request, do something with it
printf("client says: %.*s\n", len, &conn->rbuf[4]);
// generating echoing response
memcpy(&conn->wbuf[0], &len, 4);
memcpy(&conn->wbuf[4], &conn->rbuf[4], len);
conn->wbuf_size = 4 + len;
// remove the request from the buffer.
// note: frequent memmove is inefficient.
// note: need better handling for production code.
size_t remain = conn->rbuf_size - 4 - len;
if (remain) {
memmove(conn->rbuf, &conn->rbuf[4 + len], remain);
}
conn->rbuf_size = remain;
// change state
conn->state = STATE_RES;
state_res(conn);
// continue the outer loop if the request was fully processed
return (conn->state == STATE_REQ);
}
try_one_request
函数从读取缓冲区中取出一个请求,生成一个响应,然后转移到 STATE_RES
状态。简单来说,这个函数的作用是处理一个请求,并在处理完成后进入一个新的状态。
STATE_RES为写入:
static void state_res(Conn *conn) {
while (try_flush_buffer(conn)) {}
}
static bool try_flush_buffer(Conn *conn) {
ssize_t rv = 0;
do {
size_t remain = conn->wbuf_size - conn->wbuf_sent;
rv = write(conn->fd, &conn->wbuf[conn->wbuf_sent], remain);
} while (rv < 0 && errno == EINTR);
if (rv < 0 && errno == EAGAIN) {
// got EAGAIN, stop.
return false;
}
if (rv < 0) {
msg("write() error");
conn->state = STATE_END;
return false;
}
conn->wbuf_sent += (size_t)rv;
assert(conn->wbuf_sent <= conn->wbuf_size);
if (conn->wbuf_sent == conn->wbuf_size) {
// response was fully sent, change state back
conn->state = STATE_REQ;
conn->wbuf_sent = 0;
conn->wbuf_size = 0;
return false;
}
// still got some data in wbuf, could try to write again
return true;
}
具体来说,代码执行了以下操作:
flushes the write buffer:代码清空(flush)了写缓冲区(write buffer)。写缓冲区是用于临时存储即将写入磁盘或其他存储介质的数据的区域。
until it got EAGAIN:代码会持续清空写缓冲区,直到遇到一个特定的错误或状态——EAGAIN
。EAGAIN
是一个在Unix和类Unix系统中常见的错误码,表示操作无法立即完成,需要稍后再试。这通常发生在系统资源暂时不可用的情况下,比如缓冲区满了,或者系统负载很高。
or transits back to the STATE_REQ if the flushing is done:如果写缓冲区已经被成功清空,代码会返回到一个名为STATE_REQ
的状态。这里的STATE_REQ
很可能是一个程序内部定义的状态,表示需要进行下一步操作或请求。
总结来说,这段代码描述了一个处理写缓冲区的过程:持续清空缓冲区,直到遇到EAGAIN
错误或成功清空缓冲区后返回到STATE_REQ
状态。这个过程是很多涉及I/O操作的程序中常见的模式,用于确保数据正确、及时地写入存储设备。
// the `query` function was simply splited into `send_req` and `read_res`.
static int32_t send_req(int fd, const char *text);
static int32_t read_res(int fd);
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
die("socket()");
}
// code omitted...
// multiple pipelined requests
const char *query_list[3] = {"hello1", "hello2", "hello3"};
for (size_t i = 0; i < 3; ++i) {
int32_t err = send_req(fd, query_list[i]);
if (err) {
goto L_DONE;
}
}
for (size_t i = 0; i < 3; ++i) {
int32_t err = read_res(fd);
if (err) {
goto L_DONE;
}
}
L_DONE:
close(fd);
return 0;
}
本章详细讲述了如何使用C++实现一个回声服务器的事件循环。介绍了结构体Conn的定义和读写缓冲区。通过poll函数监控文件描述符的活动,并根据连接状态处理IO操作。文中还讨论了处理新连接、状态机读写、协议解析以及测试服务器的方法。
客户端代码:https://build-your-own.org/redis/06/06_client.cpp.htm
服务端代码:https://build-your-own.org/redis/06/06_server.cpp.htm