用C/C++构建自己的Redis——第三章、回声服务器(实现事件循环)

用C/C++构建自己的Redis——第三章、回声服务器(实现事件循环)


文章目录

  • 用C/C++构建自己的Redis——第三章、回声服务器(实现事件循环)
  • 前言
  • 一、概况
  • 二、主体方法
    • 2.1 新的连接
    • 2.2 状态机:读取
    • 2.3 解析协议
    • 2.4 状态机:写入
  • 三、测试
  • 总结


前言

数据结构,很多初学者对它的实际用处了解较少,《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操作(如读或写)可能不会立即完成,而是被推迟执行。为了处理这种情况,需要使用缓冲区来暂存数据。当读操作被推迟时,可以将数据先读取到缓冲区中;当写操作被推迟时,可以先将数据写入缓冲区,等待后续时机再发送。

状态的作用:通过定义不同的状态,可以决定如何处理连接。文中提到了两种状态:

  1. STATE_REQ:表示当前连接处于读取请求的状态。在这个阶段,服务器会尝试从客户端读取数据,并将读取到的数据存储到缓冲区中。
  2. STATE_RES:表示当前连接处于发送响应的状态。在这个阶段,服务器会尝试将缓冲区中的数据发送给客户端。

事件循环代码如下:

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返回后,我们会得到通知,知道哪些文件描述符准备好了进行读取/写入,然后相应地采取行动。

二、主体方法

2.1 新的连接

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
    }
}

2.2 状态机:读取

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)。

此外,读取系统调用(以及任何其他系统调用)在接收到 errnoEINTR 的错误时需要重试。EINTR 表示系统调用被信号中断了。即使我们的应用程序不使用信号,也需要重试,因为系统调用可能因为其他原因被中断,例如操作系统调度或其他系统级事件。

2.3 解析协议

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 状态。简单来说,这个函数的作用是处理一个请求,并在处理完成后进入一个新的状态。

2.4 状态机:写入

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;
}

具体来说,代码执行了以下操作:

  1. flushes the write buffer:代码清空(flush)了写缓冲区(write buffer)。写缓冲区是用于临时存储即将写入磁盘或其他存储介质的数据的区域。

  2. until it got EAGAIN:代码会持续清空写缓冲区,直到遇到一个特定的错误或状态——EAGAINEAGAIN是一个在Unix和类Unix系统中常见的错误码,表示操作无法立即完成,需要稍后再试。这通常发生在系统资源暂时不可用的情况下,比如缓冲区满了,或者系统负载很高。

  3. or transits back to the STATE_REQ if the flushing is done:如果写缓冲区已经被成功清空,代码会返回到一个名为STATE_REQ的状态。这里的STATE_REQ很可能是一个程序内部定义的状态,表示需要进行下一步操作或请求。

总结来说,这段代码描述了一个处理写缓冲区的过程:持续清空缓冲区,直到遇到EAGAIN错误或成功清空缓冲区后返回到STATE_REQ状态。这个过程是很多涉及I/O操作的程序中常见的模式,用于确保数据正确、及时地写入存储设备。

三、测试

  1. 我们可以利用上一章的客户端来测试服务器,它们使用的协议是一样的。
  2. 我们还可以对客户端进行修改,以展示循环的客户端操作。
// 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

你可能感兴趣的:(Redis,c语言,c++,redis,服务器)