网络编程

网络编程

基本socket模型

IO多路复用

I/O多路复用实际上是将等待IO请求的责任交给了内核,内核管理所有已经建立连接的socket,当程序调用查询函数时返回给程序去处理。

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

select poll
组织方式 固定长度的 BitsMap 动态数组
检查方式 遍历 遍历
时间复杂度 O(n) O(n)
文件描述符 最大1024 无限制

epoll

epoll的基本流程如下

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
    int n = epoll_wait(...);
    for(接收到数据的socket){
        //处理
    }
}

epoll 通过两个方面,很好解决了 select/poll 的问题。

  1. epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  2. epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

边缘触发和水平触发

边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

select和poll只能工作在水平触发模式。

epoll可以工作在水平触发和边缘触发,但处在边缘触发模式时,最好搭配非阻塞IO,否则可能阻塞在某一个连接上而无法处理其他连接。举一个例子,我们一般是通过epoll_wait获取当前需要业务处理的fd,用一个while循环读取数据,如果是非阻塞IO,读完数据会返回一个EAGIAIN错误,表示当前没有数据可以读取;如果是阻塞IO,没有数据也不会返回,而是阻塞等待到有数据可以读取,这样本来负责所有连接的线程现在只能阻塞等待一个连接的业务了。以上是read的情况,write同理。

所以,epoll处于边缘触发时,最好搭配非阻塞IO。

此外,Linux 手册关于 select 的内容中有如下说明:

Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.

即就算多路复用返回了事件,事件也不一定是可读写的,比如数据到达了,但经检查后发现数据错误丢弃。

如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

总的来说,使用IO多路复用时,尽量使用非阻塞IO。

select/poll和epoll区别如下

select/poll epoll
组织方式 线性结构 红黑树
检查方式 遍历 索引
时间复杂度 O(n) O(log n)
触发方式 仅水平触发 可水平触发可边缘触发

IO:阻塞\非阻塞\同步\异步

在前面我们知道了,I/O 是分为两个过程的:

  1. 数据准备的过程
  2. 数据从内核空间拷贝到用户进程缓冲区的过程

阻塞和非阻塞都是同步IO,区别就在与是否在第一个步骤「数据准备」。

  • 阻塞IO会在「数据准备」的过程中阻塞等待,无法执行。
  • 非阻塞IO会在数据未准备时,返回一个特殊值,表示还未准备好,可以执行其他命令。

阻塞IO流程如下

非阻塞IO流程如下

如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。

同步IO是在第二步骤「数据从内核空间拷贝到用户进程缓冲区的过程」阻塞,而异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作

异步IO流程如下

高性能网络模式

高性能网络模式都基于于epoll实现,搭配一定的多线程、多进程。

Reactor

Reactor就是使用epoll接受请求的线程,处理连接请求,当读写事件到来,通常会将具体操作分发给其他线程。

常用的架构有

  • 单 Reactor 单线程 / 多进程
  • 单 Reactor 多线程 / 多进程
  • 多 Reactor 多线程 / 多进程

单 Reactor 单线程 / 多进程

在这个架构中,Reactor负责接收请求,同时接收请求后需要自己负责处理,并没有用到其他线程或进程。实际上就是接收后调用对应对象的成员函数。

优点:单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。

缺点

  1. 无法充分利用 多核 CPU 的性能
  2. Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景

Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单 Reactor 多线程 / 多进程

鉴于单线程的缺点,便有了多线程的方案,单 Reactor 多线程的方案如下。

的Reactor负责接收请求,对于建立连接是需要自己完成的。

而对于具体业务的处理,是通过通知线程池,通知完就可以接着监听事务了。让线程池内的线程去处理具体的业务逻辑。

通知的方式可以是

  • 需要处理的连接的信息发送给线程安全的队列,并使用条件变量通知一个线程,线程池中的一个线程拿到并执行。
  • 每个线程一个线程安全的队列,通过哈希等分配函数进行具体的分配。

聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。

事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。

而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。

另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

多 Reactor 多线程 / 多进程

要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多线程 / 进程的方案,架构图如下。

多Reactor实际上就是不同的Reactor承担不同的任务,有用与连接的Reactor,和两个负责业务处理的reactor。

对于连接请求,会在MainReactor处建立连接,连接建立完成则从MainReactor中移除,加入SubReactor,进行实际的业务处理。

大名鼎鼎的两个开源软件 NettyMemcache 都采用了「多 Reactor 多线程」的方案。

采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。

具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。

Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

不仅仅数据准备的过程不需要等待,数据从内核拷贝到用户区也不用等待,这些过程都由内核管理。

Proactor模式流程如下

  • 首先用户线程负责初始化Proactor并且创建对应的处理对象。
  • 当有通知时,内核直接调用注册好的回调函数。

可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。

而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。

API

更详细的API介绍见每个函数的man手册。

大小端转换API:

n: network 网络

h: host (本地)主机

l: unsigned long

s: unsigned short

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

IP地址转换API

计算机认识到的是二进制(转换结果是无符号整型但会被计算机视作二进制),而更具可读性的是点分十进制。

因此存在两者之间的转换。

#include <arpa/inet.h>

int inet_aton(const char* cp, struct in_addr* inp); //直接传inp的指针作为结果,返回值表示是否成功。
//(inp实际指向一个struct, struct中仅含一个in_addr_t(即uint32_t) 成员)

in_addr_t inet_addr(const char* strptr); //将点分十进制转成二进制(in_addr_in实际上是一个unsigned int)
char* inet_ntoa(struct in_addr in); //将网络字节序转化为address(IP字符串)。
//注意该函数返回的char*指向的是内部的静态资源,之前的指针会指向当前转换的结果。即,该函数不可重入。


//以上都是IPv4,以下是根据参数来转换IPv4还是IPv6。
int inet_pton(int af, const char* src, void* dst);
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);

注意:由于是转换到网络字节序,因此转换时还存在大小端的转换。

Socket API

int socket(int domain, int type, int protocol);

//client part
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

//server part
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr* addr, socklen_t addrlen);

数据读写

//TCP part
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
ssize_t send(int sockfd, const void* buf, size_t len, int flags);

//UDP part
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, const struct sockaddr* src_addr, socklen_t addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t addrlen);

TODO: Out of Band

Socket设置

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

相关参数:

SO_REUSEADDR; //复用处于Time_Wait阶段的Socket,或者直接修改内核参数/proc/sys/ipv4/tcp_tw_reuse
SO_RCVBUF; //设置receive buffer
SO_SNDBUF; //设置send buff
SO_RCVLOWAT; //缓冲大小到达该阈值才通知可读
SO_SNDLOWAD; //同上,但可写。
SO_LINGER; //控制TCP关闭时的行为。

网络信息API

struct hostent *gethostbyname(const char *name);

#include <sys/socket.h>       /* for AF_INET */
struct hostent *gethostbyaddr(const void *addr,
                              socklen_t len, int type);

//可重入版本
int gethostent_r(
               struct hostent *ret, char *buf, size_t buflen,
               struct hostent **result, int *h_errnop);

int gethostbyaddr_r(const void *addr, socklen_t len, int type,
                    struct hostent *ret, char *buf, size_t buflen,
                    struct hostent **result, int *h_errnop);

//如下查询man手册即可。
getservbyname();
getservbyport();
getaddrinfo();
getnameinfo();

错误信息

char* strerror(int errnum); //将错误码转成字符串。
const char *gai_strerror(int errcode); //getaddrinfo, getnameinfo的错误码转成字符串。
...

参考


网络编程
https://messenger1th.github.io/2024/07/24/Linux/网络编程/
作者
Epoch
发布于
2024年7月24日
许可协议