25
2025
06
19:00:21

io_uring 实例教程:第3部分—— 使用 io_uring 构建的 Web 服务器

io_uring By Example: Part 3 – A Web Server with io_uring

本文是 io_uring 系列文章的一部分

  • 系列介绍
  • 第一部分:io_uring 简介。在本文中,我们基于原生 io_uring 接口创建了 cat_uring,并基于更高级的 liburing 构建了 cat_liburing
  • 第二部分:批量操作排队:我们开发了一个文件复制程序 cp_liburing,利用 io_uring 处理多个请求。
  • 第三部分:即本文

我们在第一部分中讨论过,由于 select()poll() 和 epoll 会将对本地/常规文件的操作始终报告为就绪状态,像 libuv(NodeJS 就基于此库)这样的库会使用单独的线程池来处理文件 I/O。io_uring 的一个巨大优势在于,它为多种类型的 I/O 提供了一个单一、简洁、统一且最重要的是可用的接口。

在这个示例中,我们将研究另一个操作——accept(),以及如何使用 io_uring 来实现它。再结合 readv() 和 writev() 操作,你就具备了编写一个简单 Web 服务器的能力!这个 Web 服务器基于我为 ZeroHTTPd1编写的代码。ZeroHTTPd 是我在一系列文章中用于探索各种 Linux 进程模型及其性能对比的程序。我已将 ZeroHTTPd 重写为完全使用 io_uring 接口。

1 https://unixism.net/2019/04/linux-applications-performance-introduction/

以下是通过 ZeroHTTPd 提供服务的首页:

ZeroHTTPd 首页
ZeroHTTPd 首页

现在让我们开始看代码。


#include <stdio.h>
#include <netinet/in.h>
#include <string.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <liburing.h>
#include <sys/stat.h>
#include <fcntl.h>

#define SERVER_STRING           "Server: zerohttpd/0.1\r\n"
#define DEFAULT_SERVER_PORT     8000
#define QUEUE_DEPTH             256
#define READ_SZ                 8192

#define EVENT_TYPE_ACCEPT       0
#define EVENT_TYPE_READ         1
#define EVENT_TYPE_WRITE        2

struct request {
    int event_type;
    int iovec_count;
    int client_socket;
    struct iovec iov[];
};

struct io_uring ring;

const char *unimplemented_content = \
        "HTTP/1.0 400 Bad Request\r\n"
        "Content-type: text/html\r\n"
        "\r\n"
        "<html>"
        "<head>"
        "<title>ZeroHTTPd: Unimplemented</title>"
        "</head>"
        "<body>"
        "<h1>Bad Request (Unimplemented)</h1>"
        "<p>Your client sent a request ZeroHTTPd did not understand and it is probably not your fault.</p>"
        "</body>"
        "</html>";

const char *http_404_content = \
        "HTTP/1.0 404 Not Found\r\n"
        "Content-type: text/html\r\n"
        "\r\n"
        "<html>"
        "<head>"
        "<title>ZeroHTTPd: Not Found</title>"
        "</head>"
        "<body>"
        "<h1>Not Found (404)</h1>"
        "<p>Your client is asking for an object that was not found on this server.</p>"
        "</body>"
        "</html>";

/*
 * 将字符串转换为小写的实用函数。
 * */
void strtolower(char *str) {
    for (; *str; ++str)
        *str = (char)tolower(*str);
}

/*
有一个函数,它会打印系统调用以及错误详情,
然后以错误代码 1 退出程序。非零的错误代码意味着程序运行出现了问题。
*/ 
void fatal_error(const char *syscall) {
    perror(syscall);
    exit(1);
}

/*
 * 辅助函数,用于使代码看起来更简洁。
 * */
void *zh_malloc(size_t size) {
    void *buf = malloc(size);
    if (!buf) {
        fprintf(stderr, "Fatal error: unable to allocate memory.\n");
        exit(1);
    }
    return buf;
}

/*
 * 此函数负责为 Web 服务器设置主监听套接字。
 * */
int setup_listening_socket(int port) {
    int sock;
    struct sockaddr_in srv_addr;

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        fatal_error("socket()");

    int enable = 1;
    if (setsockopt(sock,
                   SOL_SOCKET, SO_REUSEADDR,
                   &enable, sizeof(int)) < 0)
        fatal_error("setsockopt(SO_REUSEADDR)");


    memset(&srv_addr, 0, sizeof(srv_addr));
    srv_addr.sin_family = AF_INET;
    srv_addr.sin_port = htons(port);
    srv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    /* 我们将套接字绑定到一个端口,并将其转换为监听套接字。
     * */
    if (bind(sock,
             (const struct sockaddr *)&srv_addr,
             sizeof(srv_addr)) < 0)
        fatal_error("bind()");

    if (listen(sock, 10) < 0)
        fatal_error("listen()");

    return (sock);
}

int add_accept_request(int server_socket, struct sockaddr_in *client_addr,
                       socklen_t *client_addr_len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, server_socket, (struct sockaddr *) client_addr,
                         client_addr_len, 0);
    struct request *req = malloc(sizeof(*req));
    req->event_type = EVENT_TYPE_ACCEPT;
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);

    return 0;
}

int add_read_request(int client_socket) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct request *req = malloc(sizeof(*req) + sizeof(struct iovec));
    req->iov[0].iov_base = malloc(READ_SZ);
    req->iov[0].iov_len = READ_SZ;
    req->event_type = EVENT_TYPE_READ;
    req->client_socket = client_socket;
    memset(req->iov[0].iov_base, 0, READ_SZ);
    
    /* Linux 内核 5.5 支持 `readv` 函数,但不支持 `recv()` 或 `read()` 函数。 */
    io_uring_prep_readv(sqe, client_socket, &req->iov[0], 1, 0);
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);
    return 0;
}

int add_write_request(struct request *req) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    req->event_type = EVENT_TYPE_WRITE;
    io_uring_prep_writev(sqe, req->client_socket, req->iov, req->iovec_count, 0);
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);
    return 0;
}

void _send_static_string_content(const char *str, int client_socket) {
    struct request *req = zh_malloc(sizeof(*req) + sizeof(struct iovec));
    unsigned long slen = strlen(str);
    req->iovec_count = 1;
    req->client_socket = client_socket;
    req->iov[0].iov_base = zh_malloc(slen);
    req->iov[0].iov_len = slen;
    memcpy(req->iov[0].iov_base, str, slen);
    add_write_request(req);
}

/*
 * 当 ZeroHTTPd 遇到除 GET 或 POST 之外的任何其他 HTTP 方法时,会使用此函数通知客户端。
 * */
void handle_unimplemented_method(int client_socket) {
    _send_static_string_content(unimplemented_content, client_socket);
}

/*
 * 当请求的文件未找到时,此函数用于向客户端发送“HTTP 404 Not Found”状态码及相应消息。
 * */ 
void handle_http_404(int client_socket) {
    _send_static_string_content(http_404_content, client_socket);
}

/*
 * 一旦确定要提供静态文件服务,就会使用此函数来读取该文件,并通过 Linux 的 `sendfile()` 系统调用将其写入客户端套接字。这样就避免了我们将文件缓冲区在内核空间和用户空间之间来回传输的麻烦。
 * */
void copy_file_contents(char *file_path, off_t file_size, struct iovec *iov) {
    int fd;

    char *buf = zh_malloc(file_size);
    fd = open(file_path, O_RDONLY);
    if (fd < 0)
        fatal_error("read");

    /* We should really check for short reads here */
    int ret = read(fd, buf, file_size);
    if (ret < file_size) {
        fprintf(stderr, "Encountered a short read.\n");
    }
    close(fd);

    iov->iov_base = buf;
    iov->iov_len = file_size;
}

/*
 * 这是一个简单的函数,用于获取我们即将提供服务的文件的扩展名。
 * */
const char *get_filename_ext(const char *filename) {
    const char *dot = strrchr(filename, '.');
    if (!dot || dot == filename)
        return"";
    return dot + 1;
}

/*
 * 此函数用于发送 HTTP 200 OK 响应头和服务器信息字符串。对于某些类型的文件,它还能根据文件扩展名发送对应的内容类型。此外,它还会发送内容长度响应头。最后,它会单独发送一行 "\r\n",以此表示响应头结束、内容开始。
 * */ 
void send_headers(const char *path, off_t len, struct iovec *iov) {
    char small_case_path[1024];
    char send_buffer[1024];
    strcpy(small_case_path, path);
    strtolower(small_case_path);

    char *str = "HTTP/1.0 200 OK\r\n";
    unsigned long slen = strlen(str);
    iov[0].iov_base = zh_malloc(slen);
    iov[0].iov_len = slen;
    memcpy(iov[0].iov_base, str, slen);

    slen = strlen(SERVER_STRING);
    iov[1].iov_base = zh_malloc(slen);
    iov[1].iov_len = slen;
    memcpy(iov[1].iov_base, SERVER_STRING, slen);

    /*
     * 检查文件扩展名,以确定网页上某些常见类型的文件,并发送合适的内容类型响应头。
     * 由于文件扩展名可能大小写混合,例如 JPG、jpg 或 Jpg,
     * 因此在检查之前,我们会将扩展名转换为小写。
     * */
    const char *file_ext = get_filename_ext(small_case_path);
    if (strcmp("jpg", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: image/jpeg\r\n");
    if (strcmp("jpeg", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: image/jpeg\r\n");
    if (strcmp("png", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: image/png\r\n");
    if (strcmp("gif", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: image/gif\r\n");
    if (strcmp("htm", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: text/html\r\n");
    if (strcmp("html", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: text/html\r\n");
    if (strcmp("js", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: application/javascript\r\n");
    if (strcmp("css", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: text/css\r\n");
    if (strcmp("txt", file_ext) == 0)
        strcpy(send_buffer, "Content-Type: text/plain\r\n");
    slen = strlen(send_buffer);
    iov[2].iov_base = zh_malloc(slen);
    iov[2].iov_len = slen;
    memcpy(iov[2].iov_base, send_buffer, slen);

    /* 发送内容长度响应头,在这种情况下,该长度即为文件的大小。 */
    sprintf(send_buffer, "content-length: %ld\r\n", len);
    slen = strlen(send_buffer);
    iov[3].iov_base = zh_malloc(slen);
    iov[3].iov_len = slen;
    memcpy(iov[3].iov_base, send_buffer, slen);

    /*
     * 当浏览器看到单独一行中出现 "\r\n" 序列时,
     * 它就会明白响应头到此结束,后续可能会有内容。
     * */
    strcpy(send_buffer, "\r\n");
    slen = strlen(send_buffer);
    iov[4].iov_base = zh_malloc(slen);
    iov[4].iov_len = slen;
    memcpy(iov[4].iov_base, send_buffer, slen);
}

void handle_get_method(char *path, int client_socket) {
    char final_path[1024];

    /*
     如果路径以斜杠结尾,那么客户端可能想要获取该目录下的索引文件。
     */
    if (path[strlen(path) - 1] == '/') {
        strcpy(final_path, "public");
        strcat(final_path, path);
        strcat(final_path, "index.html");
    }
    else {
        strcpy(final_path, "public");
        strcat(final_path, path);
    }

    /* stat() 系统调用可以提供有关文件的信息,
     * 比如文件类型(普通文件、目录等)、文件大小等。 */
    struct stat path_stat;
    if (stat(final_path, &path_stat) == -1) {
        printf("404 Not Found: %s (%s)\n", final_path, path);
        handle_http_404(client_socket);
    }
    else {
        /* 检查这是否为普通文件,而不是目录或其他类型的文件 */
        if (S_ISREG(path_stat.st_mode)) {
            struct request *req = zh_malloc(sizeof(*req) + (sizeof(struct iovec) * 6));
            req->iovec_count = 6;
            req->client_socket = client_socket;
            send_headers(final_path, path_stat.st_size, req->iov);
            copy_file_contents(final_path, path_stat.st_size, &req->iov[5]);
            printf("200 %s %ld bytes\n", final_path, path_stat.st_size);
            add_write_request( req);
        }
        else {
            handle_http_404(client_socket);
            printf("404 Not Found: %s\n", final_path);
        }
    }
}

/*
 * 此函数会查看所使用的方法,并调用相应的处理函数。
 * 由于我们仅实现了 GET 和 POST 方法,若这两种方法都不匹配,
 * 则会调用 handle_unimplemented_method() 函数。该函数会向客户端发送错误信息。
 * */
void handle_http_method(char *method_buffer, int client_socket) {
    char *method, *path, *saveptr;

    method = strtok_r(method_buffer, " ", &saveptr);
    strtolower(method);
    path = strtok_r(NULL, " ", &saveptr);

    if (strcmp(method, "get") == 0) {
        handle_get_method(path, client_socket);
    }
    else {
        handle_unimplemented_method(client_socket);
    }
}

int get_line(const char *src, char *dest, int dest_sz) {
    for (int i = 0; i < dest_sz; i++) {
        dest[i] = src[i];
        if (src[i] == '\r' && src[i+1] == '\n') {
            dest[i] = '\0';
            return 0;
        }
    }
    return 1;
}

int handle_client_request(struct request *req) {
    char http_request[1024];
    /* 获取第一行内容,这一行将是请求信息 */
    if(get_line(req->iov[0].iov_base, http_request, sizeof(http_request))) {
        fprintf(stderr, "Malformed request\n");
        exit(1);
    }
    handle_http_method(http_request, req->client_socket);
    return 0;
}

void server_loop(int server_socket) {
    struct io_uring_cqe *cqe;
    struct sockaddr_in client_addr;
    socklen_t client_addr_len = sizeof(client_addr);

    add_accept_request(server_socket, &client_addr, &client_addr_len);

    while (1) {
        int ret = io_uring_wait_cqe(&ring, &cqe);
        struct request *req = (struct request *) cqe->user_data;
        if (ret < 0)
            fatal_error("io_uring_wait_cqe");
        if (cqe->res < 0) {
            fprintf(stderr, "Async request failed: %s for event: %d\n",
                    strerror(-cqe->res), req->event_type);
            exit(1);
        }

        switch (req->event_type) {
            case EVENT_TYPE_ACCEPT:
                add_accept_request(server_socket, &client_addr, &client_addr_len);
                add_read_request(cqe->res);
                free(req);
                break;
            case EVENT_TYPE_READ:
                if (!cqe->res) {
                    fprintf(stderr, "Empty request!\n");
                    break;
                }
                handle_client_request(req);
                free(req->iov[0].iov_base);
                free(req);
                break;
            case EVENT_TYPE_WRITE:
                for (int i = 0; i < req->iovec_count; i++) {
                    free(req->iov[i].iov_base);
                }
                close(req->client_socket);
                free(req);
                break;
        }
        /* Mark this request as processed */
        io_uring_cqe_seen(&ring, cqe);
    }
}

void sigint_handler(int signo) {
    printf("^C pressed. Shutting down.\n");
    io_uring_queue_exit(&ring);
    exit(0);
}

int main() {
    int server_socket = setup_listening_socket(DEFAULT_SERVER_PORT);

    signal(SIGINT, sigint_handler);
    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
    server_loop(server_socket);

    return 0;
}


程序结构

在做任何其他事情之前,main() 函数会调用 setup_listening_socket() 函数,让服务器在指定端口上进行监听。不过,我们不会调用 accept() 函数来实际接受连接。我们会通过向 io_uring 发送请求来完成这一操作,后续会对此进行详细解释。

程序的核心是 server_loop() 函数,该函数会(自身或通过其他函数)向 io_uring 提交请求,等待完成队列条目,并对其进行处理。下面让我们详细了解一下这个函数。

void server_loop(int server_socket) {
    struct io_uring_cqe *cqe;
    struct sockaddr_in client_addr;
    socklen_t client_addr_len = sizeof(client_addr);

    add_accept_request(server_socket, &client_addr, &client_addr_len);

    while (1) {
        int ret = io_uring_wait_cqe(&ring, &cqe);
        struct request *req = (struct request *) cqe->user_data;
        if (ret < 0)
            fatal_error("io_uring_wait_cqe");
        if (cqe->res < 0) {
            fprintf(stderr, "Async request failed: %s for event: %d\n",
                    strerror(-cqe->res), req->event_type);
            exit(1);
        }

        switch (req->event_type) {
            case EVENT_TYPE_ACCEPT:
                add_accept_request(server_socket, &client_addr, &client_addr_len);
                add_read_request(cqe->res);
                free(req);
                break;
            case EVENT_TYPE_READ:
                if (!cqe->res) {
                    fprintf(stderr, "Empty request!\n");
                    break;
                }
                handle_client_request(req);
                free(req->iov[0].iov_base);
                free(req);
                break;
            case EVENT_TYPE_WRITE:
                for (int i = 0; i < req->iovec_count; i++) {
                    free(req->iov[i].iov_base);
                }
                close(req->client_socket);
                free(req);
                break;
        }
        /* Mark this request as processed */
        io_uring_cqe_seen(&ring, cqe);
    }   
}

在进入 while 循环之前,我们调用 add_accept_request() 函数提交一个 accept() 请求。这样,服务器就可以接受任何客户端的连接。下面让我们详细了解一下这个函数。

int add_accept_request(int server_socket, struct sockaddr_in *client_addr,
                       socklen_t *client_addr_len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, server_socket, (struct sockaddr *) client_addr,
                         client_addr_len, 0);
    struct request *req = malloc(sizeof(*req));
    req->event_type = EVENT_TYPE_ACCEPT;
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);

    return 0;
}

我们获取一个提交队列条目(SQE),并使用 liburing 库中的 io_uring_prep_accept() 函数准备一个 accept() 操作以便提交。我们使用 request 结构体来跟踪每个提交的请求。这些结构体实例包含了每个请求从一个状态转换到下一个状态的上下文信息。下面让我们看一下 request 结构体:

struct request {
    int event_type;
    int iovec_count;
    int client_socket;
    struct iovec iov[];
};

客户端请求会经历 3 个状态,上述结构体可以保存足够的信息来处理这些状态之间的转换。客户端请求的三个状态分别是:

已接受 -> 请求读取 -> 响应写入

下面让我们看一下在完成端的大型 switch/case 代码块中,accept() 操作完成后会发生什么:

           case EVENT_TYPE_ACCEPT:
                add_accept_request(server_socket, &client_addr, &client_addr_len);
                add_read_request(cqe->res);
                free(req);
                break;

在处理完上一个 accept() 请求后,我们会在提交队列中添加一个新的 accept() 请求。否则,我们的程序将无法接受来自客户端的任何新连接。然后,我们调用 add_read_request() 函数,该函数会提交一个 readv() 请求,以便我们可以从客户端读取 HTTP 请求。这里有几点需要说明:我们本可以使用 read() 函数,但直到内核版本 5.6 时 io_uring 才支持该操作,在撰写本文时,5.6 是最新的稳定版本,至少在几个月内很多发行版中都不会采用这个版本。此外,使用 readv() 和 writev() 函数可以让我们构建许多通用逻辑,尤其是在缓冲区管理方面,后续我们会看到这一点。现在,让我们看一下 add_read_request() 函数:

int add_read_request(int client_socket) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    struct request *req = malloc(sizeof(*req) + sizeof(struct iovec));
    req->iov[0].iov_base = malloc(READ_SZ);
    req->iov[0].iov_len = READ_SZ;
    req->event_type = EVENT_TYPE_READ;
    req->client_socket = client_socket;
    memset(req->iov[0].iov_base, 0, READ_SZ);
    /* Linux kernel 5.5 has support for readv, but not for recv() or read() */
    io_uring_prep_readv(sqe, client_socket, &req->iov[0], 1, 0);
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);
    return 0;
}

正如你所见,这个函数非常直接。我们分配一个足够大的缓冲区来保存客户端请求,在提交请求之前,调用 liburing 库中的 io_uring_prep_readv() 函数。在完成端,相应的处理是通过 switch/case 代码块中的条件来完成的:

            case EVENT_TYPE_READ:
                if (!cqe->res) {
                    fprintf(stderr, "Empty request!\n");
                    break;
                }
                handle_client_request(req);
                free(req->iov[0].iov_base);
                free(req);
                break;

在这里,本质上我们调用 handle_client_request() 函数来处理 HTTP 请求。如果一切顺利,并且客户端请求的是磁盘上的一个文件,那么将运行以下这段代码:

    struct request *req = zh_malloc(sizeof(*req) + (sizeof(struct iovec) * 6));
    req->iovec_count = 6;
    req->client_socket = client_socket;
    set_headers(final_path, path_stat.st_size, req->iov);
    copy_file_contents(final_path, path_stat.st_size, &req->iov[5]);
    printf("200 %s %ld bytes\n", final_path, path_stat.st_size);
    add_write_request( req);

set_headers() 函数会设置总共 5 个小缓冲区,这些缓冲区由 5 个不同的 struct iovec 结构体表示。最后一个 iovec 实例包含正在读取的文件内容。最后,调用 add_write_request() 函数添加一个提交队列条目:

int add_write_request(struct request *req) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    req->event_type = EVENT_TYPE_WRITE;
    io_uring_prep_writev(sqe, req->client_socket, req->iov, req->iovec_count, 0);
    io_uring_sqe_set_data(sqe, req);
    io_uring_submit(&ring);
    return 0;
}

这个提交操作会让内核通过客户端套接字写出响应头和文件内容,从而完成请求/响应周期。以下是在完成阶段我们要做的事情:

    case EVENT_TYPE_WRITE:
        for (int i = 0; i < req->iovec_count; i++) {
            free(req->iov[i].iov_base);
        }
        close(req->client_socket);
        free(req);
        break;

我们释放所创建的所有由 iovec 指向的缓冲区,释放请求结构体实例,并且关闭客户端套接字,从而完成对 HTTP 请求的处理。

总结

我希望你阅读这一系列文章时能有愉快的体验。我在尝试使用 io_uring 时确实玩得很开心。io_uring 仍处于发展初期。随着时间的推移,它会不断增加新特性,性能也会不断提升。我相信它很快就会被各种软件项目所采用。

源代码

所有示例的完整源代码可以在 GitHub 上找到。

完整源代码: https://github.com/shuveb/io_uring-by-example

关于作者

我叫 Shuveb Hussain,是这个专注于 Linux 的博客的作者。你可以在 Twitter 上关注我,我会发布与技术相关的内容,主要聚焦于 Linux、性能、可扩展性和云技术。

Src

https://unixism.net/2020/04/io-uring-by-example-part-3-a-web-server-with-io-uring/




推荐本站淘宝优惠价购买喜欢的宝贝:

image.png

本文链接:https://www.hqyman.cn/post/11896.html 非本站原创文章欢迎转载,原创文章需保留本站地址!

分享到:
打赏





休息一下~~


« 上一篇 下一篇 »

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

请先 登录 再评论,若不是会员请先 注册

您的IP地址是: