从WebServer开始的系统编程

MaG1ciaN 2025-2-2
Post on:2025-2-2|Last edited: 2025-2-9|
type
status
date
slug
summary
tags
category
icon
password
 
网络编程接口

1. Socket 创建与配置

socket():创建 socket

c

c

#include <sys/types.h>#include <sys/socket.h> int socket(int domain, int type, int protocol);
C
  • 参数
    • domain:协议族,如 AF_INET(IPv4)、AF_INET6(IPv6)。
    • type:socket 类型,如 SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)。
    • protocol:通常为 0,表示默认协议。
  • 返回值:成功返回 socket 文件描述符,失败返回 -1。

bind():绑定地址

c
复制

c

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
C
  • 参数
    • sockfd:socket 文件描述符。
    • addr:指向地址结构体的指针(如 struct sockaddr_in)。
    • addrlen:地址结构体的大小。
  • 返回值:成功返回 0,失败返回 -1。

listen():监听连接(TCP)

c
复制

c

int listen(int sockfd, int backlog);
C
  • 参数
    • sockfd:socket 文件描述符。
    • backlog:等待连接队列的最大长度。
  • 返回值:成功返回 0,失败返回 -1。

accept():接受连接(TCP)

c
复制

c

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
C
  • 参数
    • sockfd:监听 socket 文件描述符。
    • addr:用于存储客户端地址的结构体。
    • addrlen:地址结构体的大小。
  • 返回值:成功返回新的 socket 文件描述符,失败返回 -1。

2. 连接与通信

connect():发起连接(TCP/UDP)

c
复制

c

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
C
  • 参数
    • sockfd:socket 文件描述符。
    • addr:目标服务器地址。
    • addrlen:地址结构体的大小。
  • 返回值:成功返回 0,失败返回 -1。

send() / recv():发送和接收数据(TCP)

c
复制

c

ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv(int sockfd, void *buf, size_t len, int flags);
C
  • 参数
    • sockfd:socket 文件描述符。
    • buf:数据缓冲区。
    • len:缓冲区大小。
    • flags:标志位,通常为 0。
  • 返回值:成功返回实际发送/接收的字节数,失败返回 -1。

sendto() / recvfrom():发送和接收数据(UDP)

c
复制

c

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
C
  • 参数
    • dest_addr:目标地址(sendto 使用)。
    • src_addr:源地址(recvfrom 使用)。
  • 返回值:成功返回实际发送/接收的字节数,失败返回 -1。

3. 地址转换与配置

inet_pton():将字符串 IP 地址转换为二进制

c

plain

#include <arpa/inet.h>int inet_pton(int af, const char *src, void *dst);
Plain text
  • 参数
    • af:地址族,如 AF_INET 或 AF_INET6
    • src:字符串形式的 IP 地址。
    • dst:存储二进制结果的缓冲区。
  • 返回值:成功返回 1,失败返回 0 或 -1。

inet_ntop():将二进制 IP 地址转换为字符串

c
复制

plain

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
Plain text
  • 参数
    • src:二进制形式的 IP 地址。
    • dst:存储字符串结果的缓冲区。
    • size:缓冲区大小。
  • 返回值:成功返回指向 dst 的指针,失败返回 NULL。

4. 多路复用与异步 I/O

select():多路复用

plain

#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
Plain text
  • 参数
    • nfds:最大文件描述符 + 1。
    • readfds / writefds / exceptfds:监听读、写、异常事件的文件描述符集合。
    • timeout:超时时间。
  • 返回值:成功返回就绪的文件描述符数量,失败返回 -1。

poll():多路复用

 

plain

#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
Plain text
  • 参数
    • fds:监听的文件描述符数组。
    • nfds:数组大小。
    • timeout:超时时间(毫秒)。
  • 返回值:成功返回就绪的文件描述符数量,失败返回 -1。

epoll():高效多路复用

plain

#include <sys/epoll.h>int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
Plain text
  • 参数
    • epfd:epoll 实例的文件描述符。
    • op:操作类型(EPOLL_CTL_ADDEPOLL_CTL_MODEPOLL_CTL_DEL)。
    • fd:监听的文件描述符。
    • event:事件结构体。
  • 返回值epoll_wait 返回就绪的事件数量,失败返回 -1。

5. 关闭与清理

close():关闭 socket

 

plain

#include <unistd.h>int close(int fd);
Plain text
  • 参数
    • fd:文件描述符。
  • 返回值:成功返回 0,失败返回 -1。

shutdown():关闭连接

 

plain

int shutdown(int sockfd, int how);
Plain text
  • 参数
    • how:关闭方式(SHUT_RDSHUT_WRSHUT_RDWR)。
  • 返回值:成功返回 0,失败返回 -1。

6. 其他常用函数

getsockopt() / setsockopt():获取/设置 socket 选项

 

plain

int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
Plain text
  • 参数
    • level:选项级别(如 SOL_SOCKET)。
    • optname:选项名称(如 SO_REUSEADDR)。
    • optval:选项值。
    • optlen:选项值的大小。

getaddrinfo():解析主机名和服务名

 

plain

#include <netdb.h>int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res); void freeaddrinfo(struct addrinfo *res);
Plain text
  • 参数
    • node:主机名或 IP 地址。
    • service:服务名或端口号。
    • hints:提示信息。
    • res:返回的地址信息链表。
  • 返回值:成功返回 0,失败返回错误码。
epoll 是 Linux 中用于高效 I/O 事件通知的机制,常用于高并发网络服务器中。它支持两种事件触发模式:水平触发(Level-Triggered, LT) 和 边缘触发(Edge-Triggered, ET)。这两种模式的行为和适用场景有所不同。

1. 水平触发(Level-Triggered, LT)

  • 行为
    • 只要文件描述符(fd)处于就绪状态(例如,有数据可读或可写),epoll 就会持续通知应用程序。
    • 如果应用程序没有处理完所有数据,epoll 会在下一次调用 epoll_wait 时再次通知。
  • 特点
    • 适合初学者或对事件处理逻辑要求不高的场景。
    • 编程模型相对简单,不容易遗漏事件。
    • 可能会重复通知,导致效率稍低。
  • 示例
    • 假设一个 socket 上有数据可读:
    • 如果应用程序只读取了部分数据,epoll 会继续通知,直到所有数据被读取完毕。

2. 边缘触发(Edge-Triggered, ET)

  • 行为
    • 只有当文件描述符的状态发生变化时(例如,从无数据到有数据),epoll 才会通知应用程序。
    • 如果应用程序没有处理完所有数据,epoll 不会再次通知,除非状态再次发生变化。
  • 特点
    • 适合高性能场景,减少重复通知的开销。
    • 编程模型复杂,需要确保一次性处理完所有数据,否则可能会丢失事件。
    • 通常与非阻塞 I/O 结合使用。
  • 示例
    • 假设一个 socket 上有数据可读:
    • 如果应用程序只读取了部分数据,epoll 不会再次通知,除非有新的数据到达。

对比总结

特性
水平触发(LT)
边缘触发(ET)
通知频率
只要 fd 就绪,就会重复通知
只在状态变化时通知一次
编程复杂度
简单,适合初学者
复杂,需要确保一次性处理所有数据
性能
可能较低,因为重复通知
较高,减少不必要的通知
适用场景
一般场景,逻辑简单的应用
高性能场景,如高并发服务器
与非阻塞 I/O 结合
不强制要求
必须结合非阻塞 I/O 使用

使用示例

水平触发(LT)

c
复制

plain

struct epoll_event ev; ev.events = EPOLLIN; // 监听可读事件(水平触发) epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
Plain text

边缘触发(ET)

c
复制

plain

struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 监听可读事件(边缘触发) epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
Plain text

选择建议

  • 如果你需要简单易用的模型,或者不确定如何处理所有数据,可以选择 水平触发(LT)
  • 如果你追求高性能,并且能够确保一次性处理完所有数据,可以选择 边缘触发(ET)
在实际开发中,边缘触发通常与非阻塞 I/O 结合使用,以避免因未处理完数据而导致的事件丢失问题。
 
 
 
 
 
io_uring
io_uring 是 Linux 内核提供的一种高性能异步 I/O 框架,旨在解决传统异步 I/O 接口(如 aio)的局限性,并提供更高的性能和灵活性。它由 Jens Axboe 开发,从 Linux 5.1 版本开始引入,并逐渐成为 Linux 高性能 I/O 操作的首选方案。

核心概念

io_uring 的核心思想是通过两个环形队列(ring buffer)实现用户空间和内核空间的高效通信:
  1. 提交队列(Submission Queue, SQ):用户程序将 I/O 请求提交到 SQ。
  1. 完成队列(Completion Queue, CQ):内核将处理完成的 I/O 操作结果放入 CQ,用户程序从 CQ 中读取结果。
这种设计避免了传统异步 I/O 中频繁的系统调用和上下文切换,从而显著提升了性能。

主要特点

  1. 高性能
      • 通过共享内存实现用户空间和内核空间的零拷贝通信。
      • 支持批量提交和完成 I/O 请求,减少系统调用开销。
  1. 灵活性
      • 支持多种 I/O 操作类型,包括文件读写、网络操作、定时器等。
      • 可以通过扩展支持更多的操作类型。
  1. 易用性
      • 提供了丰富的 API 和工具库(如 liburing),简化了开发。
      • 支持轮询模式(polling),进一步减少延迟。
  1. 可扩展性
      • 支持多线程和多核环境下的高效并发。
      • 可以通过注册文件描述符和缓冲区来优化性能。

使用场景

  • 高性能服务器(如 Web 服务器、数据库)。
  • 需要高吞吐量和低延迟的 I/O 密集型应用。
  • 替代传统的 epoll 和 aio 接口。

示例代码

以下是一个简单的 io_uring 示例,用于异步读取文件:
c
复制

c

#include <liburing.h>#include <stdio.h>#include <fcntl.h>#include <unistd.h>#include <string.h>#define BUF_SIZE 4096int main() { struct io_uring ring; io_uring_queue_init(32, &ring, 0); int fd = open("testfile.txt", O_RDONLY); if (fd < 0) { perror("open"); return 1; } char *buf = malloc(BUF_SIZE); struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0); io_uring_submit(&ring); struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); if (cqe->res < 0) { fprintf(stderr, "Async read failed: %s\n", strerror(-cqe->res)); return 1; } printf("Read %d bytes: %.*s\n", cqe->res, cqe->res, buf); io_uring_cqe_seen(&ring, cqe); io_uring_queue_exit(&ring); close(fd); free(buf); return 0; }
C

总结

io_uring 是 Linux 异步 I/O 的未来方向,特别适合需要高性能和低延迟的应用场景。通过其高效的环形队列设计和丰富的功能,开发者可以轻松构建高性能的 I/O 密集型程序。如果你对性能有较高要求,io_uring 是一个非常值得学习和使用的工具。
 
 
 
 
 
 
 
io model
从用户进程的角度看,用户进程是通过I/O相关的系统调用(简称I/O系统调用)来进行I/O操作的。在UNIX环境中,I/O系统调用有多种不同类型的执行模型。根据Richard Stevens的经典书籍“UNIX Network Programming Volume 1: The Sockets Networking ”的6.2节“I/O Models ”的介绍,大致可以分为五种I/O执行模型(I/O Execution Model,简称IO Model, IO模型):
  • blocking IO
  • nonblocking IO
  • IO multiplexing
  • signal driven IO
  • asynchronous IO
当一个用户进程发出一个 read I/O系统调用时,主要经历两个阶段:
  1. 等待数据准备好 (Waiting for the data to be ready)
  1. 把数据从内核拷贝到用户进程中(Copying the data from the kernel to the process)
上述五种IO模型在这两个阶段有不同的处理方式。需要注意,阻塞与非阻塞关注的是进程的执行状态:
  • 阻塞:进程执行系统调用后会被阻塞
  • 非阻塞:进程执行系统调用后不会被阻塞
同步和异步关注的是消息通信机制:
  • 同步:用户进程与操作系统(设备驱动)之间的操作是经过双方协调的,步调一致的
  • 异步:用户进程与操作系统(设备驱动)之间并不需要协调,都可以随意进行各自的操作

阻塞IO(blocking IO)

基于阻塞IO模型的文件读系统调用 – read 的执行过程如下图所示:
notion image
从上图可以看出执行过程包含如下步骤:
  1. 用户进程发出 read 系统调用;
  1. 内核发现所需数据没在I/O缓冲区中,需要向磁盘驱动程序发出I/O操作,并让用户进程处于阻塞状态;
  1. 磁盘驱动程序把数据从磁盘传到I/O缓冲区后,通知内核(一般通过中断机制),内核会把数据从I/O缓冲区拷贝到用户进程的buffer中,并唤醒用户进程(即用户进程处于就绪态);
  1. 内核从内核态返回到用户态的进程,此时 read 系统调用完成。
所以阻塞IO(blocking IO)的特点就是用户进程在I/O执行的两个阶段(等待数据和拷贝数据两个阶段)都是阻塞的。
当然,如果正好用户进程所需数据位于内存中,那么内核会把数据从I/O缓冲区拷贝到用户进程的buffer中,并从内核态返回到用户态的进程, read 系统调用完成。这个由于I/O缓冲带了的优化结果不会让用户进程处于阻塞状态。

非阻塞IO(non-blocking IO)

基于非阻塞IO模型的文件读系统调用 – read 的执行过程如下图所示:
notion image
从上图可以看出执行过程包含如下步骤:
  1. 用户进程发出 read 系统调用;
  1. 内核发现所需数据没在I/O缓冲区中,需要向磁盘驱动程序发出I/O操作,并不会让用户进程处于阻塞状态,而是立刻返回一个error;
  1. 用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作(这一步操作可以重复多次);
  1. 磁盘驱动程序把数据从磁盘传到I/O缓冲区后,通知内核(一般通过中断机制),内核在收到通知且再次收到了用户进程的system call后,会马上把数据从I/O缓冲区拷贝到用户进程的buffer中;
  1. 内核从内核态返回到用户态的进程,此时 read 系统调用完成。
所以,在非阻塞式IO的特点是用户进程不会被内核阻塞,而是需要用户进程不断的主动询问内核所需数据准备好了没有。非阻塞系统调用相比于阻塞系统调用的的差异在于在被调用之后会立即返回。
使用系统调用 fcntl( fd, F_SETFL, O_NONBLOCK ) 可以将对某文件句柄 fd 进行的读写访问设为非阻塞IO模型的读写访问。

多路复用IO(IO multiplexing)

IO multiplexing对应的I/O系统调用是 select 和 epoll 等,也称这种IO方式为事件驱动IO(event driven IO)。 select 和 epoll 的优势在于,采用单进程方式就可以同时处理多个文件或网络连接的I/O操作。其基本工作机制就是通过 select 或 epoll 系统调用来不断的轮询用户进程关注的所有文件句柄或socket,当某个文件句柄或socket有数据到达了,select 或 epoll 系统调用就会返回到用户进程,用户进程再调用 read 系统调用,让内核将数据从内核的I/O缓冲区拷贝到用户进程的buffer中。
在多路复用IO模型中,对于用户进程关注的每一个文件句柄或socket,一般都设置成为non-blocking,只是用户进程是被 select 或 epoll 系统调用阻塞住了。select/epoll 的优势并不会导致单个文件或socket的I/O访问性能更好,而是在有很多个文件或socket的I/O访问情况下,其总体效率会高。
基于多路复用IO模型的文件读的执行过程如下图所示:
notion image

信号驱动IO(signal driven I/O)

当进程发出一个 read 系统调用时,会向内核注册一个信号处理函数,然后系统调用返回。进程不会被阻塞,而是继续执行。当内核中的IO数据就绪时,会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。此模型的特点是,采用了回调机制,这样开发和调试应用的难度加大。
基于信号驱动IO模型的文件读的执行过程如下图所示:
notion image

异步IO(Asynchronous I/O)

用户进程发起 async_read 异步系统调用之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度看,当它收到一个 async_read 异步系统调用之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞情况。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会通知用户进程,告诉它read操作完成了。
基于异步IO模型的文件读的执行过程如下图所示:
notion image
注解
Linux异步IO的历史
2003年,Suparna Bhattacharya提出了Async I/O在Linux kernel的设计方案,里面谈到了用Full async state machine模型来避免阻塞,把一系列的阻塞点用状态机来驱动,把用户态的buffer映射到内核来驱动,这个模型被应用到Linux kernel 2.4中。在出现io_uring 之前,虽然还出现了一系列的异步IO的探索(syslet、LCA、FSAIO、AIO-epoll等),但性能一般,实现和使用复杂,应该说Linux没有提供完善的异步IO(网络IO、磁盘IO)机制。io_uring 是由 Jens Axboe提供的异步 I/O 接口,io_uring围绕高效进行设计,采用一对共享内存ringbuffer用于应用和内核间通信,避免内存拷贝和系统调用。io_uring的实现于 2019 年 5 月合并到了 Linux kernel 5.1 中,现在已经在多个项目中被使用。

五种IO执行模型对比

这里总结一下阻塞IO、非阻塞IO、同步IO、异步IO的特点:
  • 阻塞IO:在用户进程发出IO系统调用后,进程会等待该IO操作完成,而使得进程的其他操作无法执行。
  • 非阻塞IO:在用户进程发出IO系统调用后,如果数据没准备好,该IO操作会立即返回,之后进程可以进行其他操作;如果数据准备好了,用户进程会通过系统调用完成数据拷贝并接着进行数据处理。
  • 同步IO:导致请求进程阻塞/等待,直到I/O操作完成。
  • 异步IO:不会导致请求进程阻塞。
从上述分析可以得知,阻塞和非阻塞的区别在于内核数据还没准备好时,用户进程是否会阻塞(第一阶段是否阻塞);同步与异步的区别在于当数据从内核copy到用户空间时,用户进程是否会阻塞/参与(第二阶段是否阻塞)。
所以前述的阻塞IO(blocking IO),非阻塞IO(non-blocking IO),多路复用IO(IO multiplexing),信号驱动IO都属于同步IO(synchronous IO)。这四种模型都有一个共同点:在第二阶段阻塞/参与,也就是在真正IO操作 read 的时候需要用户进程参与,因此以上四种模型均称为同步IO模型。
有人可能会说,执行非阻塞IO系统调用的用户进程并没有被阻塞。其实这里定义中所指的 IO操作 是指实际的 IO操作 。比如,非阻塞IO在执行 read 系统调用的时候,如果内核中的IO数据没有准备好,这时候不会block进程。但是当内核中的IO数据准备好且收到用户进程发出的 read 系统调用时(处于第二阶段), 内核中的 read 系统调用的实现会将数据从kernel拷贝到用户内存中,这个时候进程是可以被阻塞的。
而异步IO则不一样,当用户进程发起IO操作之后,就直接返回做其它事情去了,直到内核发送一个通知,告诉用户进程说IO完成。在这整个过程中,用户进程完全没有被阻塞。
Lc 215 topk to quicksortLinux Proxy
Loading...