Metainfo

  • 标题: 网络编程
  • 日期: 2023-06-07 周三
  • 进度:完成
  • 总结:网络编程主要是以多个协议为基础通过调用已经实现的接口完成网络通信并实现一定程度上的自定义,epoll 是现代服务器的基石十分重要
  • 标签:
    • #socket #字节序 #TCP #UDP #epoll
    • #review #0_time #☆☆☆☆

Socket 库

Berkeley Socket 库(通常简称为 Socket 库)是一种应用程序编程接口(API),用于在计算机之间进行网络通信。它最早在 20 世纪 80 年代由加州大学伯克利分校(UC Berkeley)的计算机科学家开发,作为 BSD UNIX 操作系统的一部分。如今,Berkeley Socket 库已经成为 Unix、Linux、Windows 等多个平台上实现 TCP/IP 网络通信的事实标准。它支持多种协议,如 TCP、UDP 等,允许程序员在应用程序中实现客户端和服务器之间的通信。

Socket 库的基本概念是套接字(socket)。套接字是用于描述网络连接的数据结构,包括本地和远程地址、协议类型等信息。程序员可以通过调用 Socket 库提供的函数来创建和管理套接字,从而实现网络通信。

地址

数据传输过程中需要[[网络#定位|定位]],sockaddr_in 结构体提供了表示和操作 IPv4 网络地址的方式,包括(IP 地址和端口号)

sockaddr_in 结构体在 netinet/in.h 头文件中定义,其主要成员如下:

struct sockaddr_in {
   sa_family_t    sin_family; /* address family: AF_INET */
   in_port_t      sin_port;   /* port in network byte order */
   struct in_addr sin_addr;   /* internet address */
};

/* Internet address. */
struct in_addr {
   uint32_t       s_addr;     /* address in network byte order */
};
  1. sin_family:该字段表示地址族,对于 IPv4 地址,它的值应该设置为 AF_INET
  2. sin_port:该字段表示端口号,以网络字节序(big-endian)存储。在使用时,可以使用库函数htons()将主机字节序(host byte order)的端口号转换为网络字节序。
  3. sin_addr:该字段为 in_addr 类型,表示 IPv4 地址。IPv4 地址以网络字节序存储。可以使用库函数 inet_pton() 将点分十进制表示的 IPv4 地址转换为网络字节序的整数形式。

字节序

网络字节序

网络字节序(Network Byte Order)是一种规定字节在网络上传输时的顺序(字节序)的约定。网络字节序采用大端字节序(Big-Endian)表示。这意味着在网络上传输多字节数据(如 16 位、32 位整数等)时,较高有效位的字节(most significant byte,MSB)先被发送,较低有效位的字节(least significant byte,LSB)后被发送。即将高位储存在低地址,低位储存在高地址。

网络字节序的引入是为了解决不同计算机系统之间字节序的不一致问题。由于不同的处理器架构可能采用不同的字节序(如大端字节序或小端字节序),在进行网络通信时,为了确保数据能够正确地在不同系统之间传输和解析,需要统一使用网络字节序表示数据。

在进行网络编程时,通常需要将主机字节序(Host Byte Order)的数据转换为网络字节序,以便在网络上传输。同样,在接收到来自网络的数据后,需要将网络字节序的数据转换回主机字节序。

主机字节序

主机字节序(Host Byte Order)是指在内存中表示多字节数据(如16位、32位整数等)时,字节的存储顺序。主机字节序取决于计算机的处理器架构,通常分为两种:

  1. 大端字节序(Big-Endian):在大端字节序中,较高有效位的字节(most significant byte,MSB)存储在较低的内存地址处,而较低有效位的字节(least significant byte,LSB)存储在较高的内存地址处。例如,一个 32 位整数 0x12345678 在大端字节序的内存中表示为(从左到右地址依次增加):
+------+------+------+------+
| 0x12 | 0x34 | 0x56 | 0x78 |
+------+------+------+------+
  1. 小端字节序(Little-Endian):在小端字节序中,较低有效位的字节(least significant byte,LSB)存储在较低的内存地址处,而较高有效位的字节(most significant byte,MSB)存储在较高的内存地址处。例如,一个32位整数0x12345678在小端字节序的内存中表示为:
+------+------+------+------+
| 0x78 | 0x56 | 0x34 | 0x12 |
+------+------+------+------+

在进行网络编程时,需要注意主机字节序和网络字节序之间的转换。网络字节序是一种统一的字节序约定(采用大端字节序),用于在网络上传输数据。为了确保数据能够正确地在不同系统之间传输和解析,需要将主机字节序的数据转换为网络字节序。同样,在接收到来自网络的数据后,需要将网络字节序的数据转换回主机字节序。

Berkeley套接字库提供了一系列处理字节序转换的函数:

  1. htons():将16位短整数(如端口号)从主机字节序转换为网络字节序。
  2. htonl():将32位长整数(如IPv4地址)从主机字节序转换为网络字节序。
  3. ntohs():将16位短整数从网络字节序转换为主机字节序。
  4. ntohl():将32位长整数从网络字节序转换为主机字节序。

在实际网络编程中,需要根据处理器架构及所使用的编程语言和库,适当地进行主机字节序和网络字节序之间的转换。

转换函数

大端和小端的转换函数原型如下

#include <arpa/inet.h>

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

其中 h 表示主机字节序通常表示的是小端字节序,而 n 表示的是网络字节序通常表示大端字节序

字节序的操作的数据单位为字节,两种字节序只不过是调整了字节的顺序,下面是将 uint16_tuin32_t 的两个主机字节序转换为网络字节序的代码示例

#include <func.h>
int main()
{
    unsigned short s = 0x1234;
    printf("s = %x\n", htons(s));
    unsigned int l = 0x12345678;
    printf("l = %x\n", htonl(l));
}

十六进制中每两个数字为一字节,所以每两个数字为一组调转顺序后输出出来,结果如下

s = 3412
l = 78563412

获取 IP 地址

将域名转换为 IP 地址的过程中会先查本地的 host ,在 Linux 中键入 cat /etc/hosts 即可,如果无法查找到则会查 DNS

DNS

DNSDomain Name System,域名系统)是一个用于将人类可读的域名(例如:www.example.com)转换为与之对应的 IP 地址(例如:192.0.2.1)的分布式系统。

DNS 的优点:

  1. 提高用户体验:用户可以使用易于记忆的域名访问网站,而不是复杂的 IP 地址。
  2. 灵活性:允许网站更改其 IP 地址而不影响用户访问,因为 DNS 会自动将域名映射到新的 IP 地址。
  3. 可扩展性:DNS 是一个分布式系统,可以轻松地容纳数十亿个域名和 IP 地址。

相关函数

gethostbyname 是一个函数,用于将域名解析为 IP 地址。它是一个网络编程中常用的函数,可以用于获取一个主机的 IP 地址,以便进行网络通信。

gethostbyname 函数的参数是一个主机名,返回值是一个 hostent 结构体指针,其中包含了该主机名对应的 IP 地址等信息。如果解析失败,函数将返回空指针。

函数原型如下

#include <netdb.h>
extern int h_errno;
struct hostent *gethostbyname(const char *name);

报错会设置 h_errno 通过 strerror 可以读取错误到标准错误流,返回的结构体指针指向的 hostent 结构体如下

struct hostent {
    char  *h_name;       // 官方域名
    char **h_aliases;    // 域名的别名列表
    int    h_addrtype;   // 地址类型,通常是 AF_INET(IPv4)或 AF_INET6(IPv6)
    int    h_length;     // 地址长度,对于IPv4是4字节,对于IPv6是16字节
    char **h_addr_list;  // IP地址列表,以NULL结尾,需要使用 (struct in_addr*) 将 char* 类型的 h_addr_list[i] 转换成对应的 IP 地址指针
};

示例代码见二进制向点分十进制转换中的示例代码

点分十进制和二进制的相互转换

二进制向点分十进制转换

二进制数为 IP 地址的 struct in_addr 结构体或其指针,返回的值为对应点分十进制的字符串

inet_ntop

inet_ntop 函数如下

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数解释如下:

  • af:地址族,可以是 AF_INET 或 AF_INET6,分别代表 IPv4 和 IPv6 地址族。
  • src:要转换的二进制地址的指针,指向一个 in_addr 或 in6_addr 结构体。
  • dst:存储转换后的字符串地址的缓冲区指针。
  • size:缓冲区的长度,一般建议使用 INET_ADDRSTRLEN 或 INET6_ADDRSTRLEN 这两个预定义常量。
inet_ntoa

inet_ntoa 函数如下

char *inet_ntoa(struct in_addr in);

传入的参数为 ipv4 地址二进制表示的结构体

示例代码

通过 gethostbyname 获取 www.baidu.comIP 域名地址并使用 inet_ntopinet_ntoa 把网络字节序的域名地址转换为可读的点分十进制数据

#include <func.h>
int main(int argc, char* argv[])
{
    ARGS_CHECK(argc, 2);
    struct hostent* phost = gethostbyname(argv[1]);
    if (phost == NULL)
    {
        fprintf(stderr, "%s : %s", "gethostbyname", strerror(h_errno));
    }
    printf("name is %s\n", phost->h_name);
    for (int i = 0; phost->h_aliases[i] != NULL; i ++)
    {
        printf("    alias name is %s\n", phost->h_aliases[i]);
    }
    printf("addrtype is %d\n", phost->h_addrtype);
    printf("length is %d\n", phost->h_length);
    for (int i = 0; phost->h_addr_list[i] != NULL; i ++)
    {
	    // 将 char* 类型的 phost->h_addr_list[i] 强转为对应的 IP 结构体指针
        struct in_addr* pin = (struct in_addr*)phost->h_addr_list[i];
        char buf[1024] = {0};
        inet_ntop(phost->h_addrtype, pin, buf, INET_ADDRSTRLEN);
        printf("    IP name is %s\n", buf);
		// 使用 inet_ntoa 实现转换
        // printf("    IP name is %s\n", inet_ntoa(*pin));
    }
}

点分十进制向二进制转换

点分十进制是对应的字符串,二进制数据为 IP 地址的 struct in_addr 结构体或者结构体中的 in_addr_t 数据

inet_aton

inet_aton 的原型如下

int inet_aton(const char *cp, struct in_addr *inp);

参数说明:

  • cp:点分十进制的字符串
  • inpin_addr 结构体指针,in_addr 结构体中只有一个无符号的 32 位整型数据,表示 IP 地址的网络字节序

主调函数在栈上主动创建一个 struct in_addr 结构体并把结构体传入 inet_aton 函数,并通过指针将 IP 结构体中的 IP 网络字节序修改为对应的值

inet_addr

inet_addr 的原型如下

in_addr_t inet_addr(const char *cp);

inet_addr 直接返回 in_addr_t 类型的网络字节序,而不返回对应的结构体

示例代码
#include <func.h>
int main(int argc, char* argv[])
{
	// inet_addr 直接返回的 IP 的网络字节序
    printf("atoaddr: addr = %x\n",inet_addr(argv[1]));
	// inet_aton 还需要通过 IP 的结构体实现相同的功能
    struct in_addr addr;
    inet_aton(argv[1], &addr);
    printf("aton: addr = %x\n", addr.s_addr);
}

传递 IP 地址和端口号

sockaddr_in 是一个结构体,用于在网络编程中表示 IPv4 地址和端口号。它是一个常用的数据结构,用于 socket 编程中的地址和端口号的表示和传递。

sockaddr_in 结构体的定义如下:

struct sockaddr_in {
    sa_family_t sin_family; // 地址族,一般为 AF_INET
    uint16_t sin_port;      // 端口号,网络字节序
    struct in_addr sin_addr;// IPv4 地址,网络字节序
    char sin_zero[8];       // 未使用
};

其中,各字段的含义如下:

  • sin_family:地址族,表示该结构体中保存的是 IPv4 地址,一般为 AF_INET
  • sin_port:端口号,表示该结构体中保存的是端口号,必须使用网络字节序(即大端序)存储。
  • sin_addrIPv4 地址,表示该结构体中保存的是 IPv4 地址,必须使用网络字节序(即大端序)存储。
  • sin_zero:未使用的 8 个字节,一般初始化为 0

sockaddr_in 结构体中的字段都是网络字节序的,因此在编写网络程序时需要使用函数进行转换,例如使用 htons 函数将主机字节序的端口号转换为网络字节序的端口号。

socket 编程中,sockaddr_in 结构体通常用于表示一个 IPv4 地址和端口号的组合,例如在服务器程序中表示该服务器绑定的 IP 地址和监听的端口号,或在客户端程序中表示要连接的服务器的 IP 地址和端口号。

sockaddr_in 结构体参数设定示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
	// ./sockaddr 14.119.104.189 1234
    ARGS_CHECK(argc, 3);
    struct sockaddr_in soc;
    soc.sin_family = AF_INET;
    soc.sin_port = htons(atoi(argv[2]));
    soc.sin_addr.s_addr = inet_addr(argv[1]);
    printf("port = %x\naddr = %x\n", soc.sin_port, soc.sin_addr.s_addr);
}

输出结果为网络字节序的端口号和 IP 地址的十六进制表示

基于 TCP 的网络通信

流程

![[Pasted image 20230612195920.png|500]]

TCP(传输控制协议,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。基于 TCP 的网络通信流程涉及到客户端和服务器端的连接建立、数据传输和连接关闭三个主要阶段。以下是一个简要概述:

  1. 连接建立

    • 服务器端:
      1. 创建一个套接字(socket),并绑定(bind)到指定的 IP 地址和端口。
      2. 使套接字进入监听(listen)状态,等待客户端的连接请求。
    • 客户端:
      1. 创建一个套接字(socket)。
      2. 发起连接请求(connect),请求连接到服务器的指定 IP 地址和端口。
    • 服务器端:
      1. 接受客户端的连接请求(accept),建立一个新的套接字用于与客户端通信。
      2. 与客户端建立连接。

    三次握手完成后,TCP 连接就建立成功了。

  2. 数据传输

    客户端和服务器端通过建立好的连接(套接字)进行数据传输。TCP 提供了可靠的数据传输服务,确保数据在传输过程中不会丢失、重复或乱序。双方可以发送数据给对方,也可以接收对方发送的数据。

  3. 连接关闭

    当客户端和服务器端通信完成后,它们需要关闭连接。TCP 使用四次挥手(Four-way Handshake)来关闭连接:

    四次挥手完成后,TCP 连接关闭。客户端和服务器端可以分别关闭它们的套接字。

四元组

在计算机网络中,四元组(4-tuple)是用于唯一标识一个特定的传输层连接(如 TCPUDP 连接)的四个参数。四元组包含以下四个元素:

  1. 源 IP 地址:发送数据包的设备的 IP 地址。
  2. 源端口:发送数据包的设备上用于该连接的端口号。端口号用于区分设备上运行的不同应用程序或进程。
  3. 目标 IP 地址:接收数据包的设备的 IP 地址。
  4. 目标端口:接收数据包的设备上用于该连接的端口号。

四元组在 TCPUDP 连接中都有用,它可以帮助我们区分在同一台设备上运行的多个连接。一个四元组唯一地定义了一个网络连接,因此,在同一时间,两个不同的连接不能有相同的四元组。

socket 系统调用

Linux 中的主要的 socket 函数有:

socket

创建一个 socket,指定协议族、Socket 类型和协议类型。返回一个整数,作为 socket 描述符。语法为:

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol); 
// domain : AF_INET 表示 IPV4 网络族
// type : SOCK_STREAM 表示 TCP 协议 SOCK_DGRAM 表示 UDP 协议
// protocol : 填 0 自动捕获

bind

绑定 socket 到一个本地地址和端口。用于服务器端,套接字初始状态下并未与任何地址和端口绑定。使用 bind() 函数后,套接字就可以被分配到指定的 IP 地址和端口,从而允许其他设备在网络上找到并与之通信。语法为:

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen

监听 socket 的连接。用于服务器端。语法为:

int listen(int sockfd, int backlog); // 半连接队列的长度

当新的连接请求到达时,它们首先进入半连接队列,然后在完成 TCP 三次握手后转移到全连接队列。这两个队列的作用是处理不同阶段的连接请求。

  1. 半连接队列(SYN Queue):当客户端向服务器发送一个 SYN 数据包(TCP 三次握手的第一步)时,该连接请求首先进入半连接队列。此时的连接处于 SYN_RECV 状态,服务器已收到 SYN 数据包,但 TCP 三次握手尚未完成。半连接队列的长度受到操作系统限制,如果队列已满,新的连接请求可能会被丢弃。
  2. 全连接队列(Accept Queue):当 TCP 三次握手成功完成后,连接从半连接队列转移到全连接队列。此时的连接处于 ESTABLISHED 状态,已完成连接的建立。全连接队列的长度由 listen() 函数的 backlog 参数控制。当全连接队列已满时,服务器将不再接受新的连接请求,直到队列中的现有连接被处理(例如,使用 accept() 函数接受连接、移除队列)。

SYN 泛洪攻击中,攻击者向目标服务器发送大量的 SYN 数据包,同时伪造源 IP 地址。服务器会将每个 SYN 数据包存到半连接队列中,并发送 SYN-ACK 数据包。然而,由于源 IP 地址被伪造,客户端不会发送 ACK 数据包。这将导致服务器维持大量未完成的连接(半连接),占用其资源,从而使正常用户无法建立新的连接

accept

服务器端调用这个函数,等待客户端连接。成功连接后返回一个新的 socket 用于与客户端通信。语法为:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

connect

  • connect(): 客户端调用这个函数连接到服务器。对应着第一次握手,可以首先使用 ipconfig 获取本机的网络接口名称,使用以下命令捕获端口上的数据包
sudo tcpdump -i <Network_Interface_Name> port <num>

抓到的数据如下

20:38:51.017322 IP le-virtual-machine.55438 > 129.217.168.192.1234: Flags [S], seq 614925390, win 64240, options [mss 1460,sackOK,TS val 1604800314 ecr 0,nop,wscale 7], length 0

这是一个 tcpdump 输出的 TCP 数据包信息。下面是对这个输出的详细解释:

  • 20:38:51.017322: 数据包的时间戳,表示数据包何时被捕获。这里的时间是 20:38:51(小时:分钟:秒),毫秒部分为 017322。
  • IP: 表示此数据包是一个 IP 数据包。
  • le-virtual-machine.55438: 数据包的源地址和源端口。le-virtual-machine 是源主机名,55438 是源端口。
  • >: 方向符号,表示该数据包从源地址发送到目标地址。
  • 129.217.168.192.1234: 数据包的目标地址和目标端口。129.217.168.192 是目标 IP 地址,1234 是目标端口。
  • Flags [S]: TCP 标志位,表示此数据包携带的 TCP 标志。在这种情况下,S 表示这是一个 SYN 数据包,用于开始 TCP 三次握手过程。
  • seq 614925390: 数据包的序列号,这里是 614925390。
  • win 64240: TCP 窗口大小,表示接收方在确认之前可以接收的字节数。这里的窗口大小是 64240 字节。
  • options [mss 1460,sackOK,TS val 1604800314 ecr 0,nop,wscale 7]: TCP 头部选项,提供了关于连接的额外信息。
    • mss 1460: 最大分段大小,表示 TCP 传输的最大数据段长度。这里的 MSS 是 1460 字节。
    • sackOK: 表示此连接支持选择性确认(SACK),允许接收方在确认数据时更精确地指定哪些数据段已收到。
    • TS val 1604800314 ecr 0: 时间戳选项,用于测量往返时间(RTT)和拥塞窗口调整。val 是发送方时间戳,ecr 是回显应答时间戳。
    • nop: 无操作选项,用于对齐 TCP 选项字段。
    • wscale 7: 窗口缩放选项,允许在连接过程中动态调整 TCP 窗口大小。这里的缩放因子为 7,即窗口大小乘以 2^7(128)。
  • length 0: 数据包中携带的载荷长度。在这种情况下,数据包没有携带任何有效载荷,长度为 0。

语法为:

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

send

发送数据到 socket。语法为:

ssize_t send(int sockfd, const void *buf, size_t len, int flags); // flag = 0 时和 write 一样

recv

socket 接收数据。语法为:

ssize_t recv(int sockfd, void *buf, size_t len, int flags); 

close

关闭 socket。语法为:

int close(int fd);

select 实现通信

客户端示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
    // 客户端建立连接
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = connect(sfd, (struct sockaddr*)&addr, sizeof(addr));  
    ERROR_CHECK(ret, -1, "connect");
    puts("successful connect!");
	// select 多路 IO 复用
    fd_set rdset;
    char buf[4096] = {0};
    while (1)
    {
        FD_ZERO(&rdset);
        FD_SET(STDIN_FILENO, &rdset);
        FD_SET(sfd, &rdset);
        select(sfd + 1, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(STDIN_FILENO, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            read(STDIN_FILENO, buf, sizeof(buf));
            send(sfd, buf, strlen(buf), 0);
        }
        if (FD_ISSET(sfd, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = recv(sfd, buf, sizeof(buf), 0);
            if (ret == 0)
                break;
            printf("%s", buf);
        }
    }
    close(sfd);
}

setsockopt

由于服务端主动关闭时会进入 TIME_WAIT 状态而不能很快建立新链接,需要通过 setsockoptbind 修改为无视 TIME_WAIT 因为本身客户端的端口是随机分配的,服务端处于 TIME_WAIT 阶段也不会重复建立连接,都是全新的连接,所以可以修改,可以通过以下 shell 命令查看网络具体端口的网络连接状态

而对于本地套接字连接(也称为 UNIX 套接字或 IPC 套接字),它们通常用于同一台机器上的不同进程之间的通信。本地套接字通信是在操作系统内核中进行的,它不使用网络协议栈,因此不涉及像 TCP 连接中存在的 TIME_WAIT 状态。

在 UNIX/Linux 系统中,本地套接字是通过文件系统的 inode 来识别连接的。当服务器端或客户端关闭套接字时,如果没有其他进程持有该套接字的引用,连接就立即关闭,不会进入 TIME_WAIT 状态。然而,这并不意味着没有状态跟踪,系统仍然需要处理套接字的创建和销毁,以及处理客户端和服务器端的同步和资源回收问题。

本地套接字则是通过文件系统中的路径名来识别另一端,不涉及网络层的 IP 地址和端口号。连接的过程通常不涉及类似 TCP 的三次握手,它是通过打开一个特殊的文件类型来实现的。

本地套接字的数据传输完全在内核中进行,不通过网络协议栈,因此效率更高,延迟更低,本地套接字通常不需要这些因为网络引起的可靠性措施,因为它们用于同一台机器上的进程间通信

$ netstat -an | grep <端口>

setsocket 设置流程

int reuse = 1;
setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

服务端代码

示例如下

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
	// TCP 连接过程中服务端
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
	// 在 bind 之前修改 socket 的属性
    int reuse = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    listen(sfd, 10);
    int netFd = accept(sfd, NULL, NULL);
    puts("successful connect!");
	// select 实现多路 IO 复用
    fd_set rdset;
    char buf[4096] = {0};
    while (1)
    {
        FD_ZERO(&rdset);
        FD_SET(STDIN_FILENO, &rdset);
        FD_SET(netFd, &rdset);
        select(netFd + 1, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(STDIN_FILENO, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            read(STDIN_FILENO, buf, sizeof(buf));
            send(netFd, buf, strlen(buf), 0);
        }
        if (FD_ISSET(netFd, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = recv(netFd, buf, sizeof(buf), 0);
            if (ret == 0)
                break;
            printf("%s", buf);
        }
    }
    close(sfd);
    close(netFd);
}

保证客户端可以多次断开重连需要将 accept() 看作是 read 使用 select 监听起来,需要两个 fd_set 集合,一个存放就绪集合一个存放监听集合,处于连接状态时需要将 STDIN_FILENOnetFd 加到监听集合中去,注意此时的 select 第一个值必须是文件描述符的最大值,不能给定具体初值,示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    int reuse = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    listen(sfd, 10);

    fd_set lsset;
    fd_set rdset;
    int netFd = -1;
    FD_SET(sfd, &lsset);
    char buf[4096] = {0};
    while (1)
    {
        memcpy(&rdset, &lsset, sizeof(lsset));
        int maxFd = sfd + 1;
        if (netFd != -1) maxFd = netFd + 1;
        select(maxFd, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(sfd, &rdset))
        {
            netFd = accept(sfd, NULL, NULL);
            puts("successful connect!");
            FD_SET(netFd, &lsset);
            FD_SET(STDIN_FILENO, &lsset);
        }
        if (FD_ISSET(STDIN_FILENO, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            read(STDIN_FILENO, buf, sizeof(buf));
            send(netFd, buf, strlen(buf), 0);
        }
        if (FD_ISSET(netFd, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = recv(netFd, buf, sizeof(buf), 0);
            if (ret == 0)
            {
                puts("connetc end!");
                FD_CLR(STDIN_FILENO, &lsset);
                FD_CLR(netFd, &lsset);
                continue;
            }
            printf("%s", buf);
        }
    }
    close(sfd);
    close(netFd);
}

通过创建 netFd 数组实现聊天室的功能,服务器只负责将消息转发给所有客户端即可

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    int reuse = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    listen(sfd, 10);

    fd_set lsset;
    fd_set rdset;
    int netFd[10] = {0};
    int maxCnt = 0;
    FD_SET(sfd, &lsset);
    char buf[4096] = {0};
    while (1)
    {
        memcpy(&rdset, &lsset, sizeof(lsset));
        int maxFd = sfd + 1;
        if (maxCnt > 0) maxFd = maxCnt + 4;
        select(maxFd, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(sfd, &rdset))
        {
            netFd[maxCnt] = accept(sfd, NULL, NULL);
            puts("successful connect!");
            FD_SET(netFd[maxCnt], &lsset);
            maxCnt ++;
        }
        for (int i = 0; i < maxCnt; i ++)
        {
            if (FD_ISSET(netFd[i], &rdset))
            {
                memset(buf, 0, sizeof(buf));
                ret = recv(netFd[i], buf, sizeof(buf), 0);
                for (int j = 0; j < maxCnt; j ++)
                {
                    if (i == j) continue;
                    send(netFd[j], buf, strlen(buf), 0);
                }
            }
        }
    }
    close(sfd);
}

基于 UDP 的网络通信

当传递的内容大于接收缓冲区大小时会将多余部分舍掉而保留边界,基于 TCP 的通信与之相反

流程

![[B3~5SWWFI2E28D)%DT8WF0H.jpg|500]]

相关函数

其中 socketbindcloseTCP 中相同,sendtorecvfromsendrecv 带地址版本,因为 UDP 本身并没有建立连接,所以需要指定地址才可以传输数据

sendto

sendto 的函数原型为

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); 
// flags 取 0
// dest_addr 也需要使用 sockaddr_in 设定后强转

客户端的示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    ARGS_CHECK(argc, 3);
    int sockFd = socket(AF_INET, SOCK_DGRAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = sendto(sockFd, "hello", 5, 0, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "sendto");
    close(sockFd);
}

recvfrom

secvfrom 的函数原型为

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

示例代码

服务端的示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    int sockFd = socket(AF_INET, SOCK_DGRAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sockFd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    char buf[1024] = {0};
    struct sockaddr_in acAddr;
    socklen_t addrlen = sizeof(acAddr);
    recvfrom(sockFd, buf, sizeof(buf), 0, (struct sockaddr*)&acAddr, &addrlen);
    puts(buf);

    fd_set rdset;

    while (1)
    {
        FD_ZERO(&rdset);
        FD_SET(STDIN_FILENO, &rdset);
        FD_SET(sockFd, &rdset);
        select(sockFd + 1, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(STDIN_FILENO, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = read(STDIN_FILENO, buf, sizeof(buf));
			// 键盘键入 ctrl + d 时,读取到的 ret 为 0 此时向另一端发送一个空字符串,recvfrom 会返回 0,再主动终止
            if (ret == 0)
            {
                sendto(sockFd, NULL, 0, 0, (struct sockaddr*)&acAddr, sizeof(acAddr));
                break;
            }
            sendto(sockFd, buf, strlen(buf), 0, (struct sockaddr*)&acAddr, sizeof(acAddr));
        }
        if (FD_ISSET(sockFd, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = recvfrom(sockFd, buf, sizeof(buf), 0, NULL, NULL);
            if (ret == 0) break;
            printf("%s", buf);
        }
    }

    close(sockFd);
}

客户端的示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    ARGS_CHECK(argc, 3);
    int sockFd = socket(AF_INET, SOCK_DGRAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = sendto(sockFd, "connect success!", 16, 0, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "sendto");

    char buf[1024] = {0};
    fd_set rdset;

    while (1)
    {
        FD_ZERO(&rdset);
        FD_SET(STDIN_FILENO, &rdset);
        FD_SET(sockFd, &rdset);
        select(sockFd + 1, &rdset, NULL, NULL, NULL);
        if (FD_ISSET(STDIN_FILENO, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = read(STDIN_FILENO, buf, sizeof(buf));
            if (ret == 0)
            {
                sendto(sockFd, NULL, 0, 0, (struct sockaddr*)&addr, sizeof(addr));
                break;
            }
            sendto(sockFd, buf, strlen(buf), 0, (struct sockaddr*)&addr, sizeof(addr));
        }
        if (FD_ISSET(sockFd, &rdset))
        {
            memset(buf, 0, sizeof(buf));
            ret = recvfrom(sockFd, buf, sizeof(buf), 0, NULL, NULL);
            if (ret == 0) break;
            printf("%s", buf);
        }
    }
    close(sockFd);
}

epoll

介绍

epoll 是一种高效的 I/O 多路复用技术,主要用于 Linux 系统中处理大量并发网络连接。epoll 能够在一个线程中处理多个 I/O 事件,从而提高服务器性能。它是 selectpoll 方法的改进和替代方案。

epoll 的主要特点:

  1. 可扩展性:epoll 可以处理大量并发连接,性能不会随着连接数的增加而显著下降。
  2. 高效:epoll 使用事件驱动模型,只有发生 I/O 事件的文件描述符(file descriptor)才会触发通知,避免了无效的轮询操作。
  3. 可控制:epoll 提供了更多的控制选项,如边缘触发(edge-triggered)和水平触发(level-triggered)模式,以满足不同应用场景的需求。

与 select 的比较:

  1. 性能:select 的性能随着文件描述符数量的增加而降低,因为它需要轮询所有文件描述符,查找有 I/O 事件发生的文件描述符。而 epoll 只处理有 I/O 事件发生的文件描述符,因此性能更高。
  2. 可扩展性:select 的文件描述符数量受到操作系统限制(通常为1024个),而 epoll 没有这个限制,可以处理更多的并发连接。
  3. 内存占用:select 需要为每次调用复制文件描述符集合,这会导致内存占用较大。epoll 则使用内核空间来管理文件描述符,减少了内存占用。
  4. 触发模式:select 只支持水平触发模式,而 epoll 支持水平触发和边缘触发两种模式,提供了更多的灵活性。

总之,epoll 的性能、可扩展性和灵活性都优于 select,特别适用于处理大量并发连接的场景。然而,在某些情况下,如连接数较少或需要跨平台兼容性的应用中,select 仍然是一个可行的选择。

流程及相关函数

epoll 需要引入的库文件为 sys/epoll.h

使用 epoll 的流程通常分为以下几个步骤:

创建 epoll 实例

首先,需要使用 epoll_create() 函数创建一个新的 epoll 实例。这将返回一个文件描述符,用于表示该 epoll 实例,函数原型如下:

int epoll_create(int size); // size 填 1 即可,返回的值为 epfd

添加/修改/删除事件

使用 epoll_ctl() 函数将文件描述符及其感兴趣的事件添加到内核事件表中、修改已添加的事件或从内核事件表中删除事件,函数原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
// op 为对应的操作:EPOLL_CTL_ADD 添加事件 EPOLL_CTL_MOD 修改事件 EPOLL_CTL_DEL 删除事件
// event 指向 epoll_event 结构体 结构体中包含感兴趣的事件类型和文件描述符关联的数据

typedef union epoll_data {
   void        *ptr;
   int          fd;
   uint32_t     u32;
   uint64_t     u64;
} epoll_data_t;

struct epoll_event {
   uint32_t     events;      /* Epoll events 一般为 EPOLLIN(关联的文件描述符上有数据可读) 和 EPOLLOUT(关联的文件描述符上有数据可写) */
   epoll_data_t data;        /* User data variable 一般选 fd */
};

等待事件

使用 epoll_wait() 函数等待内核通知就绪的事件。这个函数会阻塞,直到至少有一个事件就绪或超时发生,函数原型为

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// events 是 epoll_event 数组,提前申请的就绪队列,储存已就绪的事件及其文件描述符
// timeout 的单位是 ms,当 timeout 取 -1 时表示无限阻塞,而当 timeout 取 0 时表示立刻返回

示例代码

使用 epoll 重写基于 TCP 的网络通信,并添加连接超时提醒和主动断连功能,其中主动断连即需要使用 close 关闭 netFd 文件描述符,在服务端没有连接时需要将 STDIN_FILENO 中的输入主动获取并不做处理,否则在下次连接时会重新发送给新的客户端

服务端示例代码如下

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    int reuse = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    listen(sfd, 10);

    int epfd = epoll_create(1);
    ERROR_CHECK(epfd, -1, "epoll_create");
    struct epoll_event event;
    event.data.fd = STDIN_FILENO;
    event.events = EPOLLIN;
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event);
    ERROR_CHECK(ret, -1, "epoll_ctl");
    event.data.fd = sfd;
    event.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &event);
    struct epoll_event rdEvent[3];

    int netFd = -1;
    char buf[4096] = {0};
    time_t time1, time2;
    int isConnected = 0;
    while (1)
    {
	    // 最多阻塞 2s 就会进入 if 发送 time out!
        int num = epoll_wait(epfd, rdEvent, 3, 2000);
		// 断线重连,当epoll_wait中的事件没有就绪时,返回的就绪事件个数为0
        if (num == 0 && isConnected == 1)
        {
            puts("time out!");
            time2 = time(NULL);
            // 超过 5s 时断连
            if (time2 - time1 > 5)
            {
                send(netFd, "connect timeout!", 16, 0);
				// 通过 close 服务端可以主动关闭客户端
                close(netFd);
                puts("connect timeout!");
                isConnected = 0;
                event.data.fd = netFd;
                event.events = EPOLLIN;
                epoll_ctl(epfd, EPOLL_CTL_DEL, netFd, &event);
            }   
        }
        // 遍历所有的就绪事件
        for (int i = 0; i < num; i ++)
        {
            if (rdEvent[i].data.fd == sfd)
            {
                if (isConnected == 1) continue;
                puts("connect successful!");
                netFd = accept(sfd, NULL, NULL);
                event.data.fd = netFd;
                event.events = EPOLLIN;
                epoll_ctl(epfd, EPOLL_CTL_ADD, netFd, &event);
                isConnected = 1;
                time1 = time(NULL);
                break;
            }
            if (rdEvent[i].data.fd == STDIN_FILENO)
            {
	            // 当没有连接但是标准输入就绪,需要读出来但不处理
                if (isConnected == 0) 
                {
                    read(STDIN_FILENO, buf, sizeof(buf));
                    continue;
                }
                memset(buf, 0, sizeof(buf));
                read(STDIN_FILENO, buf, sizeof(buf));
                send(netFd, buf, strlen(buf), 0);
            }
            if (rdEvent[i].data.fd == netFd)
            {
                memset(buf, 0, sizeof(buf));
                ret = recv(netFd, buf, sizeof(buf), 0);
                if (ret == 0)
                {
                    puts("connect end!");
                    event.data.fd = netFd;
                    event.events = EPOLLIN;
                    epoll_ctl(epfd, EPOLL_CTL_DEL, netFd, &event);
                    isConnected = 0;
                    close(netFd);
                }
                // 保证打印出来的 buf 只有一个换行符
                if (buf[ret - 1] == '\n') buf[ret - 1] = '\0';
                puts(buf);
                time1 = time(NULL);
            }
        }
    }
    close(sfd);
    close(netFd);
}

使用 epoll 实现聊天室的功能,因为就绪队列的大小一直在变所以需要使用 calloc 动态分配内存空间,其它和使用 select 一致

#include <func.h>
int main(int argc, char* argv[])
{
    // ./connect 192.168.217.129 1234
    ARGS_CHECK(argc, 3);
    int sockFd = socket(AF_INET, SOCK_STREAM, 0);
    int reuse = 1;
    setsockopt(sockFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(atoi(argv[2]));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    int ret = bind(sockFd, (struct sockaddr*)&addr, sizeof(addr));
    ERROR_CHECK(ret, -1, "bind");
    listen(sockFd, 10);

    int epFd = epoll_create(1);
    struct epoll_event event;
    event.data.fd = sockFd;
    event.events = EPOLLIN;
    epoll_ctl(epFd, EPOLL_CTL_ADD, sockFd, &event);
    int maxCnt = 0;
    int listenNum;
    char buf[4096] = {0};
    int netFd[20] = {0};
    struct epoll_event* events;
    while (1)
    {
        listenNum = maxCnt + 1;
        events = (struct epoll_event*)calloc(listenNum, sizeof(struct epoll_event));
        int rdNum = epoll_wait(epFd, events, listenNum, -1);
        for (int i = 0; i < rdNum; i ++)
        {
            if (events[i].data.fd == sockFd)
            {
                netFd[maxCnt] = accept(sockFd, NULL, NULL);
                puts("successful connect!");
                event.data.fd = netFd[maxCnt];
                event.events = EPOLLIN;
                epoll_ctl(epFd, EPOLL_CTL_ADD, netFd[maxCnt], &event);
                maxCnt++;
            }
            for (int j = 0; j < maxCnt; j++)
            {
                if (events[i].data.fd == netFd[j])
                {
                    memset(buf, 0, sizeof(buf));
                    recv(netFd[j], buf, sizeof(buf), 0);
                    for (int k = 0; k < maxCnt; k ++)
                    {
                        if (k == j)
                            continue;
                        send(netFd[k], buf, strlen(buf), 0);
                    }
                }
            }
        }
    }
    for (int i = 0; i < maxCnt; i ++)
        close(netFd[i]);
    close(sockFd);
}

非阻塞

epoll 的非阻塞和[[线程#锁的种类|锁]]中的非阻塞类似都可以实现进程状态的不切换,对于 epoll 来说适用于持续但不连续的读写即[[网络]]

设置非阻塞

fcntl

fcntl 可以用于控制文件描述符的属性和行为,可以使用 fcntl 来对文件描述符加上非阻塞的标志状态 O_NONBLOCK,函数原型如下

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

其中,参数说明如下:

  • fd:表示要操作的文件描述符。
  • cmd:表示要执行的操作类型,对于标志状态的修改,有两个选项
    1. F_GETFL:获取文件状态标志。
    2. F_SETFL:设置文件状态标志,先获取文件状态标志后使用或即可添加新的状态。
  • arg:表示传递给操作的参数,类型可以是整数、指针或结构体等不同类型,根据不同的操作类型决定传递参数内容和数量。

recv 临时非阻塞

recv 函数中最后一个参数可以传入 MSG_DONTWAIT 即表示非阻塞,属于是临时的非阻塞状态

示例代码

int nonblock(int fd)
{
    int status = fcntl(fd, F_GETFL);
    status |= O_NONBLOCK;
    fcntl(fd, F_SETFL, status);
    return fd;
}

int netFd = accept(sfd, NULL, NULL);
netFd = nonblock(netFd);
recv(netFd, buf, sizeof(buf), MSG_DONTWAIT);

触发条件

边缘触发(Edge Triggered,ET)和水平触发(Level Triggered,LT)是在事件驱动 IO 模型中用于描述事件通知方式的概念。

水平触发是指当一个 IO 事件发生后,通知机制会一直触发,直到应用层处理完所有事件,否则 IO 事件将会一直处于就绪状态。也就是说,一旦数据就绪,套接字就会通知应用程序,直到应用程序对该数据进行处理并清除缓冲区后,该事件才会被认为是处理完毕。

边缘触发是指只有在 IO 事件状态发生变化时,才会通知应用程序,比如从不可读变为可读或从不可写变为可写。这意味着,如果应用程序没有及时处理这些事件,那么它将会错过部分事件。也就是说,边缘触发只告诉应用程序事件是否发生了变化,而不会告诉应用程序事件的具体状态。

相对于水平触发,边缘触发通知机制更加高效,因为它只会在事件状态变化时才会通知应用程序,避免了过多的重复通知。但是,边缘触发需要应用程序及时处理事件,否则会导致事件丢失,而水平触发则可以在事件未被处理时一直通知应用程序,避免事件丢失。

示例

epoll 的水平触发将 buf 调小即可,输出结果中会将长段文字一次性全部输出

epoll 的边缘触发在通过 epoll_ctl 中的 event 设置事件属性时或上 EPOLLET 即可,如下

event.data.fd = netFd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, netFd, &event);

输出结果中为每一次就绪输出一次内容