# 前置知识:

Linux 系统 API 特点:如果调用失败一般返回 - 1

errno:属于 Linux 系统函数库,库里面的一个全局变量,记录的是最近的错误号

perror (const char *s): 用于打印 errno 对应的错误描述 所需头文件 stdio.h

close (): 关闭一个文件描述符,文件描述符可以重用 所需头文件 unistd.h

  • int open(const char *pathname,int flags);

功能:用于打开一个已经存在的文件 第一个参数:要打开的文件路径 第二个参数:权限 (三者互斥):O_RDONLY 只读 O_WRONLY 只写 O_RDWR 可读可写 返回值:成功则返回新的文件描述符,如果产生错误则返回 - 1,设置 errno 包含三个头文件:sys/types.h sys/stat.h fcntl.h

  • int open(const char *pathname,int flags,mode);

# socket 地址 API

字节序问题:CPU 累加器一次装载 4 个字节,那么 4 个字节在内存中排列的顺序将影响它被累加器装载成的整数的值

主机字节序 & 网络字节序:

  • 大端字节序:一个整数的高位字节存储在内存的低位地址,低位字节(0-7bit)存储在内存高位地址, 称为网络字节序
  • 小端字节序相反→现代多采用:称为主机字节序

# 通用 socket 地址:

AF_前缀表示地址族, PF_前缀表示协议族。

unsigned short int→两个字节

旧版:

sa_family_t sa_family;//sa_family_t 地址族类型 char sa_data [14];// 只有 14 个字节

# 专用 socket 地址

在设置和获取 IP 地址和端口号的上海更方便

TCP 协议族 sockaddr_in 和 sockaddr_in6 两个专用 socket 地址结构体,分别用 IP v4 和 IP v6 所有 socket 编程接口使用的地址参数类型都是 sockaddr→专用 socket 地址类型的遍历实际使用需要转换为通用 socket 地址类型 sockaddr

sockaddr_in 每段都划分好了相应成员,最终转换为 sockaddr 指针即可

.png)

// TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和 IPv6:
#include <netinet/in.h>
struct sockaddr_in{
sa_family_t sin_family; /* _*SOCKADDR_COMMON(sin*) */
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)];
};
struct in_addr{
in_addr_t s_addr;
};
struct sockaddr_in6{
sa_family_t sin6_family;
in_port_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))

# IP 地址转换

字符串 ip - 整数和主机 - 网络字节序的转换

通常,人们习惯用可读性好的字符串来表示 IP 地址,比如用点分十进制字符串表示 IPv4 地址,以及用十六进制字符串表示 IPv6 地址。但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址转化为可读的字符串。下面 3 个函数可用于用点分十进制字符串表示的 IPv4 地址和用网络字节序整数表示的 IPv4 地址之间的转换:

只适用于 IP v4:

#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);//该函数返回值类型为in_addr_t=uint32_t=unsigned int类型,参数为字符常量 
int inet_aton(const char *cp, struct in_addr *inp);//将点分十进制地址转换为网络字节序地址
char *inet_ntoa(struct in_addr in);

inet_addr()将一个点分十进制的 IP 字符串转换成一个网络字节序的长整数型数 (u_long 类型) inet_aton () 函数将将点分十进制地址转换为二进制的网络字节序地址,结 ** 果地址保存在结构体类型为 in_addr 的 inp 中,** 该结构体第一个成员为 uint32_t 类型(unsigned int 类型)的 in_addr_t。

  • 返回值:1 表示转换成功,0 表示失败有错误号 errno

同时适用于 IP v4 和 IP v6:

#include <arpa/inet.h>
// **p:点分十进制的IP字符串**,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
af:地址族: AF_INET AF_INET6
src:需要转换的点分十进制的IP字符串
dst:转换后的结果保存在这个里面  
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af:地址族: AF_INET AF_INET6
src: 要转换的ip的整数的地址
dst: 转换成IP地址字符串保存的地方
size:第三个参数的大小(数组的大小)
**返回值:返回转换后的数据的地址(字符串),和 dst 是一样的**
/*
#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
af:地址族: AF_INET  AF_INET6
src:需要转换的点分十进制的IP字符串
dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af:地址族: AF_INET  AF_INET6
src: 要转换的ip的整数的地址
dst: 转换成IP地址字符串保存的地方
size:第三个参数的大小(数组的大小)
返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
*/
#include <stdio.h>
#include <arpa/inet.h>
int main() {
// 创建一个ip字符串,点分十进制的IP地址字符串
char buf[] = "192.168.1.4";**//第二个参数类型为字符数组**
unsigned int num = 0;**//第三个参数的类型为无符号整型的地址---注意传入地址加&**
// 将点分十进制的IP字符串转换成网络字节序的整数
inet_pton(AF_INET, buf, &num);
unsigned char * p = (unsigned char *)&num;
//把四个字节分别打印出来:每次+1=字节+1
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
// 将网络字节序的IP整数转换成点分十进制的IP字符串
char ip[16] = "";
const char * str =  inet_ntop(AF_INET, &num, ip, 16);
printf("str : %s\n", str);
printf("ip : %s\n", ip);
printf("%d\n", ip == str);
return 0;
}

解析 unsigned char * p = (unsigned char *)#

二.(unsigned char *)&a 运算顺序 1. 先取 a 的地址 2. 将 & a 强制类型转化为 unsigned char * 类型,也就是指向 a 的地址 3. 取出 unsigned char * 指针的值

# 套接字函数

#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
  • 功能:创建一个套接字

  • 参数:

    • domain: 协议族

      AF_INET : ipv4

      AF_INET6 : ipv6

      AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)

    • type: 通信过程中使用的协议类型

      SOCK_STREAM : 流式协议

      SOCK_DGRAM : 报式协议

    • protocol : 具体的一个协议。一般写 0,则:

      • SOCK_STREAM : 流式协议默认使用 TCP
      • SOCK_DGRAM : 报式协议默认使用 UDP
  • 返回值:

    • 成功:返回文件描述符,操作的就是内核缓冲区。
    • 失败:-1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
  • 功能:绑定,将 fd 和本地的 IP + 端口进行绑定
  • 参数:
    • sockfd : 通过 socket 函数得到的文件描述符
    • addr 😗* 需要绑定的 socket 地址,这个地址封装了 ip 和端口号的信息 **
    • addrlen : 第二个参数结构体占的内存大小
int listen(int sockfd, int backlog); // /proc/sys/net/core/somaxconn
  • 功能:监听这个 socket 上的连接
  • 参数:
    • sockfd : 通过 socket () 函数得到的文件描述符
    • backlog : 未连接的和已经连接的和的最大值, 5 即可
  • 返回值:listen 成功返回 0,失败返回 - 1 并设置 errno
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
  • 参数:
    • sockfd : 用于监听的文件描述符
    • addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
    • addrlen : 指定第二个参数的对应的内存大小
  • 返回值:
    • 成功 :用于通信的文件描述符
    • -1 : 失败
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 功能: 客户端连接服务器
  • 参数:
    • sockfd : 用于通信的文件描述符
    • addr : 客户端要连接的服务器的地址信息
    • addrlen : 第二个参数的内存大小
  • 返回值:成功 0, 失败 -1
ssize_t write( int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据

fd:文件描述符 buf:读取数据存放的地方,数组的地址→传出参数 count:指定数组的大小 需要包含的头文件 unistd.h

  • read 函数返回值

0 实际读到的字节数 = 0 已经读到结尾(对端已经关闭断开连接)
-1 需要进一步判断 errno 的值 errno = EAGAIN or EWOULDBLOCK 设置了非阻塞方式读,并且没有数据到达 errno = EINTR 慢速系统调用被中断 errno = ECONNRESET 说明收到 RST 标志,连接被重置。需要 close errno = “其他” 异常

ssize_t 类型通常用于文件操作的 write、read 函数,用于表示可以被执行 read 和 write 操作的数据块的大小,其在头文件 unistd.h 中的定义如下,被 typedef 定义为__ssize_t 类型

  • 其实 ssize_t 是 signed size_t 类型,其中 size_t 类型是在标准 C 语言库中进行定义的
  • size_t 其本质是为了方便代码在不同的系统上移植而定义的,在 32 位的 Linux 中 size_t 为 unsigned int 类型即为 32 位无符号整数,在 64 位的 Linux 中其为 unsigned long 即为 64 位无符号整数。

# 以下内容源自 Linux 高性能服务器编程 / UNIX 网络编程

参考 Linux 多进程开发:进程:通信

# 客户端

# 创建 socket

# 连接函数 connect

TCP 客户通过 connect 函数来建立与 TCP 服务器的连接:

int connect (int sockfd, const struct sockaddr *servaddr,socklen_t addrlen); 返回:成功则为 0,若出错则为 - 1

解释: sockfd 是由 socket 数返回的套接字描述符 第 2 个、第 3 个参数分别是一个指向套接字地址结构的指针和该结构的大小

客户在调用函数 onnect 前不必非得调用 bind 函数, 因为如果需要的话,内核会确定源 IP 地址,并选择 个临时端口作为源端口。TCP 套接字调用 connect 函数会激发 TCP 三次握手,仅在连接成功或出错时返回。

  • connect 函数建立导致客户端套接字从 CLOSED 状态转为→SYN_SENT 状态
  • 若 connect 失败则该套接字不再可用,必须关闭,不能对该套接字再次调用 connect 函数

# 服务端

# 创建 socket:可读写控制关闭的文件描述符

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

  • domain 参数告诉系统使用哪个底层协议簇,TCP/IP 使用 PF_INET(IPV4)
  • type 指定服务类型:主要包括 SOCK_STREAM(流服务)和 SOCK_UGRAM(数据报服务)。对 TCP/IP 协议族而言,SOCK_STREAM** 表示传输层使用 TCP 协议,***SOCK_DGRAM*** 表示传输层使用 UDP 协议
  • protocol 参数是在前两个参数构成的协议集合下,再选择一个具体的协议,不过这个值通常是唯一的(由前两个参数完全决定),几乎在所有情况下都设置为 0,表示使用默认协议

调用成功返回一个 socket 文件描述符(小的非负整数值)=sockfd,失败返回 - 1 并设置 errno

# 命名 socket

创建 socket 时,我们给它指定了地址族,但并未指定具体用哪个地址 给 socket 命名:将一个 socket 与 socket 地址绑定

客户端采用匿名方式 —— 使用操作系统自动分配的 socket 地址:把 一个本地协议地址赋给 一个套接字 系统调用函数:

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

  • bind 将 my_addr 所指向的 socket 地址分配给未命名的 sockfd 文件描述符,addrlen 参数指出该 socket 地址的长度。
  • bind 成功则返回 0,失败返回 - 1 并设置 errno 包括以下两种

EACCES: 被绑定的地址是受保护的地址 EADDRINUSE: 被绑定的地址正在使用中

可以将 sizeof (addr_in) 传入第三个 参数

sizeof` 是 `C/C++` 中的一个操作符(operator),返回一个对象或者类型所占的内存字节数。其返回值类型为 `size_t

sizeof (type_name); //sizeof (类型);
sizeof (object); // 或 sizeof object 都属于 sizeof 对象;


上述两个函数都需要: include <sys/types.h> include<sys/socket.h>

# 监听 socket

socket 被命名后需要使用系统调用创建一个监听队列来存放待处理的客户连接:

int listen( int sockfd,int backlog);

listen 函数仅由 TCP 服务器调用,并做如下两件事:

  1. listen 函数创建一个套接字时,他被假设为一个主动套接字。listen 函数把 1 个未连接的套接字转换成 1 个被动套接字,指示内核接受指向套接字的连接请求。

  2. 第二个参数规定了内核应该为相应套接字排队的最大连接个数。 内核为每个给定的监听套接字维护两个队列:

    (1)未完成连接队列:每个 SYN 分节对应队列中的一项,套接字正处于 SYN_RCVD 状态

    (2)已完成连接队列:每个已完成三次握手的客户对应队列中的一项,套接字处于 ESTABLISHED 状态

1. 每当来自客户的 SYN 到达,TCP 在未完成连接队列中创建一个新项,然后服务器响应 SYN 第二个分节,并捎带对客户的 SYN 的 ACK。该项一直保留在未完成连接队列中,直到三次握手第三个分节到达或该项超时为止。如果三次握手正常,该项则从未完成连接队列转移到已完成连接队列的队尾。 2. 当进程调用 accept 函数时,已完成连接队列中的队头项将返回给进程,如果该队列为空那么进程将被投入睡眠,直到 TCP 在该队列中放入一项才唤醒它

  • sockfd 参数指定被监听的 socket。
  • backlog 参数提示内核监听队列的最大长度→监听队列长度如果超过 backlog,服务器将不手里新的客户连接,客户端将收到 ECONNREFUSED 错误信息,以前表示已连接队列 + 半连接队列之和
  • listen 成功返回 0,失败返回 - 1 并设置 errno

# 接受连接

accept 函数由 TCP 服务器调用,用于从已完成连接队列队头返回下一个已完成连接从 listen 监听队列中接受一个连接

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

  • sockfd 参数是执行过 listen 系统调用的监听 socket 套接字描述符
  • addr 参数获取被接受连接的远端 socket 地址
  • 远端 socket 地址结构长度由 * addrlen 参数所引用的整数值设置,返回时,该整数值为该套接字地址结构内的确切字节数
  • accept 成功时,那么其返回值是由 内核自动 生成的 1 个套接字全新描述符 = 称为已连接套接字描述符,该 socket 唯一地表示了被接受的这个连接。失败则返回 - 1 并设置 errno。如果已完成连接队列为空,那么进程被投入睡眠

服务器可以通过该 socket 来与被接受连接对应的客户端通信

已连接套接字每次在循环中关闭,但监听套接字在服务器的整个有效期内都保持开放

而现在由于考虑到 syn 攻击,backlog 参数的含义改为了已连接队列之和,去除了半连接队列之和了。

举一个例子,在 socket 编程当中,如果我们在服务端不用 accept 函数,listen 函数的第二个参数设置为 5,那么这个时候,可以成功连接的客户端就是最多可以成功连入 5 个,每连入一个,队列的项数就会加一 (减一的话就是用 accept 函数去取出来),所以当项数达到 5 时,客户端自然就会连不上了。

注意本函数最多返回三个值:分别对应函数三个参数

  • 新套接字描述符 / 出错指示整数
  • 客户进程的协议地址→addr
  • 客户进程的协议地址大小→addrlen

# socket 状态

  1. 调用 socket 函数创建了一个套接字以后,改套接字就对应的和相应的输出缓冲区和输入缓冲区建立了联系,此时改套接字的状态正处于 CLOSED (观察 TCP 状态转换图即可)
  2. 当我们调用 listen 函数以后,改套接字的状态就变成了 LISTEN 监听状态,此时,处于等待客户端连入的状态。
  3. 对于一个调用 listen 进行监听的套接字’操作系统会为其维护 2 个队列:未完成连接队列和已完成连接队列。 (1)未完成连接队列中的连接 当客户端发送 TCP 连接三次握手的第 1 次(即 SYN 包)时,服务器端会在未完成连接队列中创建一个与该 SYN 包对应的项,可以把该项看成一个半连接(因为连接尚未建立)该半连接的状态会从 LISTEN 变成 SYNRCVD 同时向客户端返回第 2 次握手的包。 (SYN’ACK)而此时服务器正在等待完成第 3 次握手 (2)已完成连接队列中的连接 3 次握手完成后该连接就变成 ESTABLISHED 状态,每个已经完成 3 次握手的客户端连接(完整说法应该是 “服务器端的与客户端对应的 socket 连接”)都放在这个队列中作为一项。

.png)

从上图可以看到客户端发送的三次握手从第 1 个 SYN 包到 ** 在三次握手完成之前 连接都会在未完成连接队列中;直到 在三次握手完成后 ** 该连接就从未完成连接队列转移到已完成连接队列

而 listen 函数” 曾经 “的含义为这两个队列的和不超过 backlog,实际上由于操作系统的原因可能会比这个值稍微多一些。

.png)


# Web 服务器端通过 socket 监听来自用户的请求。

源代码如下:

#include <sys/socket.h>  
#include <netinet/in.h>  
/* 创建监听socket文件描述符 */  
int listenfd = socket(PF_INET, SOCK_STREAM, 0);  /* 创建监听socket的TCP/IP的IPV4 socket地址 struct sockaddr_in address;  
bzero(&address, sizeof(address));  
address.sin_family = AF_INET;  
address.sin_addr.s_addr = htonl(INADDR_ANY);  /* INADDR_ANY:将套接字绑定到所有可用的接口   
address.sin_port = htons(port);  
int flag = 1;  
/* SO_REUSEADDR 允许端口被重复使用 */  
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));  
/* 绑定socket和它的地址 */  
ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));   
/* 创建监听队列以存放待处理的客户连接,在这些客户连接被accept()之前 */  
ret = listen(listenfd, 5);

# 代码分析:

创建监听 socket 的 TCP/IP 的 IPV4 socket 地址

  • struct sockaddr_in address;

bzero 函数是 c++ string.h 中的函数 *。* 功能描述:置字节字符串前 n 个字节为零且包括‘\0’。 原型:extern void bzero (void *s, int n); 参数说明:s 要置零的数据的起始地址;n 要置零的数据字节个数。 用法:#include <string.h> 功能: 置字节字符串 s 的前 n 个字节为零且包括‘\0’。 说明:bzero 无返回值

  • bzero(&address, sizeof(address));

创建套接字时,用该字段指定地址族,对于 TCP/IP 协议的,必须设置为 AF_INET。变量 address 是一个结构体,其中成员变量 sin_family 是地址族类型变量

  • address.sin_family = AF_INET;
  1. sin_addr 是套接字中的 IP 地址,sin_addr 的类型是联合,因此可以通过三种不同的方式访问它:作为 s_un_b(四个 1 字节整数)、s_un_w(两个 2 字节整数)或作为 s_addr(一个 4 字节整数)。INADDR_ANY:将套接字绑定到所有可用的接口
  2. 网络编程_常用的基本函数介绍 ——htonl、ntohl、htons、ntohs htonl 函数:将主机的 unsigned long 值转换成网络字节顺序(32 位)(一般主机跟网络上传输的字节顺序是不通的,分大小端),函数返回一个网络字节顺序的数字。 ntohl 函数:将网络字节顺序(32 位)转为主机字节
  • address.sin_addr.s_addr = htonl(INADDR_ANY);

htons 是将整型变量从主机字节顺序转变成网络字节顺序, 就是整数在地址空间存储方式变为高位字节存放在内存的低地址处。

  • address.sin_port = htons(port);

SO_REUSEADDR 允许端口被重复使用

  • setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));

setsockopt 函数解析:

SO_REUSEADDR 参数,打开或关闭地址复用功能。当 option_value 不等于 0 时,打开,否则,关闭。它实际所做的工作是置 sock->sk->sk_reuse 为 1 或 0。

#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
参数说明:
(1) int sockfd: 很简单,套接字描述符
(2) int level: 选项定义的层次;目前仅支持SOL_SOCKET和IPPROTO_TCP层次,若要在套接字级别上设置选项,就必须把level设置为 SOL_SOCKET
(3) int optname: 指定准备设置的选项,option_name可以有哪些取值,这取决于level
(4) const void *optval: 指针,指向存放选项值的缓冲区
(5) socklen_t optlen: optval缓冲区的长度

绑定 socket 和它的地址

  • ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));

创建监听队列以存放待处理的客户连接,在这些客户连接被 accept () 之前

  • ret = listen(listenfd, 5);

assert (ret≠-1) 函数的作用是现计算表达式 expression ,如果其值为假(即为 0),那么它先向 stderr 打印一条出错信息,然后通过调用 abort 来终止程序运行。

书本 95 页服务器程序:

const char* ip=argv[1]; int port =atoi(argv[2]); int backlog =atoi(argv[3]); int inet_pton(int AF_INET, ip ,&address.sin_addr);

需要以下包: include <sys/types.h>
include <sys/socket.h>
include <arpa/inet.h>

解释:接收 IP 地址、端口还、backlog 值。调用 inet_pton 函数:可以在将 IP 地址在 “点分十进制” 和 “整数” 之间转换

int inet_pton(int af, const char *src, void dst);
这个函数转换字符串到网络地址,第一个参数 af 是地址族,转换后存在 dst 中
inet_pton 是 inet_addr 的扩展,支持的多地址族有下列:
af = AF_INET
src 为指向字符型的地址,即 ASCII 的地址的首地址(ddd.ddd.ddd.ddd 格式的),函数将该地址
转换为 in_addr 的结构体,并复制在
dst 中

af =AF_INET6
src 为指向 IPV6 的地址,函数将该地址转换为 in6_addr 的结构体,并复制在 * dst 中如果函数出错将返回一个负值,并将 errno 设置为 EAFNOSUPPORT,如果参数 af 指定的地址族和 src 格式不对,函数将返回 0。


# 多线程实现并发服务器

参考:Linux 多线程开发

用户区中堆是共享,每个线程对应一个栈区 —— 不共享


# TCP 状态转换

(红色实现可以视为客户端发送请求,绿色虚线视为服务器,黑色是一些异常)

为什么在服务端要分两次发送 ACK 和 FIN,不一次性发送 —— 为什么第二第三次挥手不合并?

  • 因为客户端发送 FIN 后,服务端再发送 ACK 表示同意客户端断开连接,但服务端可能还需要发送数据给客户端,因此可以在服务端发送完数据后再发送 FIN 给客户端,表示服务端断开连接。
  • 三次握手时可以一次性发送:因为建立连接是双方互相的通信,而四次挥手是单方面的意愿

为什么连接的主动关闭方必须处于 TIME_WAIT 状态并持续 2MSL 时间?MSL 指一个片段在网络中最大的存活时间,2MSL 就是一个发送和一个回复所需的最大时间

  • 确保安全性:如果服务端没有接收到客户端最后发送的 ACK,那么能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK,直到确认服务端收到为止。
  • 主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号,被动关闭方也不会重传),而是因为被动关闭方重传了它的 FIN。事实上,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK。

2MSL(Maximum Segment Lifetime),主动断开连接的一方,最后进入一个 TIME_WAIT 状态,这个状态会持续: 2msl。msl: 官方建议: 2 分钟,实际是 30s

# 半关闭

半关闭状态:FIN_WAIT_1 不能发送数据(不包括协议 ACK 确认),但可以接收数据

当 TCP 连接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2 状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时 A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。从程序的角度,可以使用 API 来控制实现半连接状态:

#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd: 需要关闭的socket的描述符
how: 允许为shutdown操作选择以下几种方式:
    SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
    SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
    SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。

使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。shutdown *** 不考虑描述符的引用计数,直接关闭描述符 ***。也可选择中止一个方向的连接,只中止读或只中止写

多进程中,创建的子进程和父进程共享文件描述符表,创建一个子进程引用计数加一

注意: 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。 在多进程中如果一个进程调用了 shutdown (sfd, SHUT_RDWR) 后,其它的进程将无法通过该文件描述符 sfd 进行通信。但如果一个进程 close (sfd) 将不会影响到其它进程。

# 端口复用

首先有个问题:通信双方有一方先断开连接,比如服务端先断开连接,那么处于 FIN_WAIT_2,客户端处于 CLOSE_WAIT,而客户端也断开连接,那么服务端处于 TIME_WAIT,此时服务端需要等待 2msl 时间才能释放它所占用的端口号,期间如果重新启动服务器./server 那么会一直显示: bind: Address already in use

如果希望服务器主动结束后能立刻运行,那么需要端口复用!

tcp 协议中 FIN_WAIT2 到 Time_wait 的状态是有时间的,如果超过这个时间,服务端内核就会直接结束,所以如果服务端在 FIN_WAIT_2 状态一定时间后会自动结束进程

当客户端也断开后(给服务端发送了 FIN),服务端进入 TIME_WAIT

端口复用最常用的用途是:

  • 防止服务器重启时之前绑定的端口还未释放
  • 程序突然退出而系统没有释放端口
  • 还能设置套接字的属性

# recv 函数

**int recv( SOCKET s, char FAR *buf, int len, int flags ); **

不论是客户还是服务器应用程序都用 recv 函数从 TCP 连接的另一端接收数据。

  • 该函数的第一个参数指定接收端套接字描述符;
  • 第二个参数指明一个缓冲区,该缓冲区用来存放 recv 函数接收到的数据;
  • 第三个参数指明 buf 的长度;
  • 第四个参数一般置 0。

这 里只描述同步 Socket 的 recv 函数的执行流程: 当应用程序调用 recv 函数时,recv 先等待 s 的发送缓冲中的数据被协议传送完毕,如果协议在传送 s 的发送缓冲中的数据时出现网络错误 ,那么 recv 函数返回 SOCKET_ERROR

如果 s 的发送缓冲中没有数 据或者数据被协议成功发送完毕后,recv 先检查套接字 s 的接收缓冲区,如果 s 接收缓冲区中没有数据或者协议正在接收数据,那么 recv 就一直等待,只到 协议把数据接收完毕。当协议把数据接收完毕,recv 函数就把 s 的接收缓冲中的数据 copy 到 buf 中(注意协议接收到的数据可能大于 buf 的长度,所以 在这种情况下要调用几次 recv 函数才能把 s 的接收缓冲中的数据 copy 完。recv 函数仅仅是 copy 数据,真正的接收数据是协议来完成的),recv 函数返回其实际 copy 的字节数。

如果 recv 在 copy 时出错,那么它返回 SOCKET_ERROR;如果 recv 函数在等待协议接收数据时网络中断 了,那么它返回 0

#include <sys/types.h>
#include <sys/socket.h>
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数(在UNP(Unix网络编程)书籍中使用):
  • sockfd : 要操作的文件描述符

  • level : 级别 - SOL_SOCKET (端口复用的级别)

  • optname : 选项的名称,

    端口复用都可以使用

    • SO_REUSEADDR
    • SO_REUSEPORT
  • optval : 端口复用的值(整型)

    • 1 : 可以复用
    • 0 : 不可以复用
  • optlen : optval 参数的大小

端口复用,设置的时机是 ** 在服务器绑定端口之前 ****。 **setsockopt (); bind ();

常看网络相关信息的命令
netstat
参数:
-a 所有的socket
-p 显示正在使用socket的程序的名称
-n 直接使用IP地址,而不通过域名服务器

.png)

0.0.0.0ip 绑定了 9999 端口号,server 是应用程序的名称

# I/O 多路复用(I/O 多路转接)面试必问

# select、poll 和 epoll 要手动写出来,知道原理

  1. I/O 多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 select、poll 和 epoll
  2. 之前的做法是一个一个文件描述符去遍历,无法同时监听

输入输出对应的是程序和内存: 输入:程序 / 文件→内存 输出:内存→文件

.png)

  1. 阻塞等待 ((阻塞 IO 模型即 BIO 模型)

.png)

.png)

每有一个客户端连接进来,就创建一个线程去读取接收客户端数据,而主线程不受影响

# 非阻塞,忙轮询(非阻塞模型即 NIO)

可以设置 read 不阻塞,此时有数据就读,没数据就返回一个值继续往下执行,但需要不断循环判断是否有数据进来

.png)

当有客户端连接进来,accept 就将该 cfd 添加到一个表里面;然后再逐个判断每个文件描述符 cfd 是否有数据通信,当有数据通信就调用 read/recv 读取,没有就继续往下循环。

当客户端数量巨大时逐个遍历是否有数据写入

IO 多路转接

.png)

.png)

现在将所有客户端的文件描述符 cfd 统一交给内核,内核去检测再返回,因此只调用了一次就查出是哪个

# select API 介绍

主旨思想: 1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。

  1. 调用一个系统函数(即 select),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行 I/O 操作时,该函数才返回。 a. 这个函数是阻塞 b. 函数对文件描述符的检测的操作是由内核完成的 3. 在返回时,它会告诉进程有多少(哪些)描述符要进行 I/O 操作
// sizeof(fd_set) = 128个字节 =1024位,每个标志位保存一个文件描述符
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • 参数:

    • nfds : 委托内核检测的最大文件描述符的值 + 1→为了能够遍历到最大的文件描述符,类似: for (int i=0;i<n+1;i++)

    • readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性。

      检测读缓冲区有没有数据

      。没有数据标志位置 0,有数据置为 1. 因此最后返回的是标志位为 1 的文件描述符。

      • 一般检测读操作
      • 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
        • 是一个传入传出参数
    • writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性

      • 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写),满了标志位置 0,不满置 1
    • exceptfds : 检测发生异常的文件描述符的集合

    • timeout : 设置的超时时间

struct timeval {
    long tv_sec; /* seconds */
    long tv_usec; /* microseconds */
};
    - NULL : 永久阻塞,直到检测到了文件描述符有变化
    - tv_sec = 0 tv_usec = 0, 不阻塞
    - tv_sec > 0 tv_usec > 0, 阻塞对应的时间
  • 返回值 :

    • -1 : 失败

    • 0 (n) : 检测的集合中有 n 个文件描述符发生了变化

// 将参数文件描述符fd对应的标志位设置为0(clear)
void FD_CLR(int fd, fd_set *set);
// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,如果是0返回0, 是1返回1
int FD_ISSET(int fd, fd_set *set);
// 将参数文件描述符fd 对应的标志位,设置为1
void FD_SET(int fd, fd_set *set);
// fd_set一共有1024 bit, 全部初始化为0
void FD_ZERO(fd_set *set);

1. 要检测读缓冲区,首先建立一个 fd_set,存放每个文件描述符读缓冲区的标志位。每个比特位代表一个文件描述符 (注意前三个是被占用的!)

2. 然后将要检测的文件描述符标志位置 1, 以上都是在用户态中进行

3. 之后调用 select,将 fd_set 拷贝到内核态检测有哪些位是有数据→置 1,没有数据的→置 0,再将结构 fd_set 返回到用户态,用户只需要一次遍历用户态中的 fd_set 就能知道有多少文件描述符有数据

.png)

# poll API 介绍及代码编写

核心:将主动询问内核转变为等待内核通知,从主动轮询→被动通知 一次系统调用 select/poll 就可以实现管理多个 client 事件(读写 accept 等),降低非阻塞 IO 频繁无效系统调用问题

select () 函数缺点:内核态中依然要遍历所有文件描述符,每次调用需要拷贝全量描述符到内核态

.png)

POLL

#include <poll.h>
struct pollfd {
    int fd; /* 委托内核检测的文件描述符 */
    short events; /* 委托内核检测文件描述符的什么事件 */
    short revents; /* 文件描述符实际发生的事件 */
};
struct pollfd myfd;
   myfd.fd = 5;
   myfd.events = POLLIN | POLLOUT;  同时检测两个事件:同时委托内核进行读写操作
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 参数:

    • fds : 是一个 struct pollfd 结构体数组,这是一个需要检测的文件描述符的集合

    • nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1

    • timeout : 阻塞时长

      0 : 不阻塞

      -1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞

      0 : 阻塞的时长

  • 返回值:

    -1 : 失败

    0(n) : 成功,n 表示检测到集合中有 n 个文件描述符发生变化

poll 数组如果已满,没有可用的位置存放新连接 accept 进来的文件描述符 cfd,那么就等待下一次处理。注意新连接的 cfd 不会丢弃,在 TCP 缓冲区中,等到 poll 数组中有可用的就可以继续连接了

如果有数据传入,则调用 read 返回 len,len=0 说明读取完毕,先 close 关闭该文件描述符,再将该结构体的文件描述符置为 - 1,fd [i].fd=-1

.png)

# epoll API 介绍

改进:

首先调用 epoll_create 在 *** 内核区创建 epoll 实例 ***—— 结构体数据

struct rb_root rbr;// 存放文件描述符,底层是红黑树 struct list_head rdlist;// 检测到发生改变的(有数据传入的)文件描述符,底层双链表

.png)

#include <sys/epoll.h>

//epoll_create 创建一个新的 epoll 实例,返回指向该实例的描述符 epollfd 用来调用所有 epoll 相关接口。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发生改变的文件描述符信息(双向链表)。

当 epollfd 不再使用时,需要调用 close () 关闭,当指向 epoll 的文件描述符关闭后内核会摧毁 epoll 实例并释放相关资源。

epoll_ctl: 将哪个客户端 fd 的哪些事件 event 交给哪个 epoll (epollfd) 来管理(增删改)

int epoll_create(int size);

  • 参数:

    size : 目前没有意义了。随便写一个数,必须大于 0

  • 返回值:

    -1 : 失败

    0 : 文件描述符,指向 epoll 实例的描述符

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 */
    epoll_data_t data; /* User data variable */
};

常见的 Epoll 检测事件:

  • EPOLLIN
  • EPOLLOUT
  • EPOLLERR

// 对 epoll 实例进行管理:添加文件描述符信息,删除信息,修改信息

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • 参数:

    • epfd : epoll 实例对应的文件描述符

    • op : 要进行什么操作

      EPOLL_CTL_ADD: 添加

      EPOLL_CTL_MOD: 修改

      EPOLL_CTL_DEL: 删除

    • fd : 要检测的文件描述符

    • event : 检测文件描述符什么事情

// 检测函数

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  • 参数:
    • epfd : epoll 实例对应的文件描述符
    • events : 传出参数,保存了发送了变化的文件描述符的信息
    • maxevents : 第二个参数结构体数组的大小
    • timeout : 阻塞时间
      • 0 : 不阻塞
      • -1 : 阻塞,直到检测到 fd 数据发生变化,解除阻塞
      • 0 : 阻塞的时长(毫秒)
  • 返回值:只返回发生变化的文件描述符到用户区
    • 成功,返回发送变化的文件描述符的个数 > 0
    • 失败 -1

问题:在最开始调用 epoll_ctl 把监听的文件描述符放进红黑树的时候传入了 & epev,也就是 epev 的指针,为什么后面传入新的文件描述符的时候可以重用这个 epev 呢,这样重用 epev 的话前面传入的监听描述符不就被改动了嘛?

epoll_ctl 会在 epoll fd 红黑树中重新添加一个节点,而不是覆盖 lfd 的节点,然后新添加的节点会关联这个 event。
至于每次重用 event,而不会影响之前的已经在 rb tree 中传入的节点,应该是拷贝了 event 的数据。

# Epoll 的工作模式

.png)

  • LT 模式:水平触发(缺省工作方式)

    a. 用户不读数据,数据一直在缓冲区,epoll 会一直通知

    b. 用户只读一部分数据,epoll 会通知

    c. 缓冲区数据读完了,不通知

只要监听的文件描述符缓冲区中有数据,就会触发 epoll_wait 有返回值,内核就会通知你这个文件描述符是就绪的,这是默认的 epoll_wait 的方式。 同时支持阻塞和非阻塞

  • ET 模式:边沿触发 —— 效率高:减少 epoll 事件被重复触发的次数

    a. 用户不读数据,数据一直在缓冲区,epoll 下次检测不通知

    b. 用户只读一部分数据,epoll 不通知

    c. 缓冲区数据读完了,不通知

1. 只有监听的文件描述符的读 / 写事件发生,才会触发 epoll_wait 有返回值;比如:当 fd 文件描述符缓冲区来了 8 字节数据,内核会通知一次,你只读两个字节,内核不会通知你缓冲区还有数据(文件描述符就绪),直到你读完所有缓冲区数据,下次数据来了内核才会通知您 2. 只支持非阻塞 —— 因为该模式要求最好当内核通知触发时一次性读取完所有缓冲区数据,那么就需要在 while 循环中不断 read 读取数据,而 read 就得设置成非阻塞,否则就会卡在那。必须使用非阻塞套接字,避免由于一个文件描述符的阻塞读 / 写把处理多个文件描述符任务饿死

struct epoll_event {
    uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};
常见的Epoll检测事件:
    EPOLLET             边沿触发(在accept后设置event)
    EPOLLIN
    EPOLLOUT

EPOLLONESHOT:即使可以使用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个 问题。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于 是就出现了两个线程同时操作一个 socket 的局面。一个 socket 连接在任一时刻都只被一个线程处理,可 以使用 epoll 的 EPOLLONESHOT 事件实现。 对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异 常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事 件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进 而让其他工作线程有机会继续处理这个 socket。

epoll 更在细致的执行流程

  • 创建内核事件表(epoll_create)。这里主要是向内核申请创建一个 fd 的文件描述符作为内核事件表(B + 树结构的文件,没有数量限制),这个描述符用来保存应用进程需要监控哪些 fd 和对应类型的事件。 (简单理解内核申请一个 B + 树来监听事件
  • 添加或移出监控的 fd 和事件类型(epoll_ctl)。调用此方法可以是向内核的内核事件表 动态的添加和移出 fd 和对应事件类型。
  • epoll_wait 绑定回调事件内核向事件表的 fd 绑定一个回调函数。当监控的 fd 活跃时,会调用 callback 函数把事件加到一个活跃事件队列里;最后在 epoll_wait 返回的时候内核会把活跃事件队列里的 fd 和事件类型返回给应用进程

总结:

  • 最后,从 epoll 整体思路上来看,采用事先就在内核创建一个事件监听表,后面只需要往里面添加移出对应事件,因为本身事件表就在内核空间,所以就避免了向 select、poll 一样每次都要把自己需要监听的事件列表传输过去,然后又传回来,这也就避免了事件信息需要在用户空间和内核空间相互拷贝的问题。
  • 然后 epoll 并不是像 select 一样去遍历事件列表,然后逐个轮询的监控 fd 的事件状态,而是事先就建立了 fd 与之对应的回调函数,当事件激活后主动回调 callback 函数,这也就避免了遍历事件列表的这个操作,所以 epoll 并不会像 select 和 poll 一样随着监控的 fd 变多而效率降低,这种事件机制也是 epoll 要比 select 和 poll 高效的主要原因。