网络编程1, 前言2, 地址处理2.1 大端地址和小端地址大端法和小端法大小端转化2.2 点分十进制转化: 要用结构体类型2.3 DNS: 记住理论3, TCP3.1 TCP通信流程3.1.1 Socket3.2.2 Bind3.1.3 Listen3.1.4 Connect3.1.5 Accept3.1.6 Send和Recv3.1.7 close3.2 TCP通信代码示例3.2.1 客户端3.2.2 服务端3.3 结合Select通信3.3.1 Select简单回顾3.3.2 Select通信示例端口占用:拓展3.3.4 Select断开重连3.4 其它: 了解3.4.1 tcpdump操作3.4.2 DDOS4, UDP4.1 UDP通信流程4.1.1 Socket4.1.2 Bind4.1.3 Sendto和Recvfrom4.1.4 Close4.2 UDP通信代码示例4.2.1 客户端4.2.2 服务端4.2. 3 练习5, Epoll5.1 Select的缺陷5.2 Epoll特点5.3 Epoll函数5.3.1 函数定义5.3.2 代码示例5.4 触发模式5.4.1 水平触发5.4.2 边缘触发5.4.3 非阻塞模式的recv4.6 其他4.6.1 修改socket的属性: 仅作了解
TCP/IP协议族标准
只规定了网络模型中各个层次
的设计和规范
,具体实现则需要由各个操作系统厂商完成。最出名的网络库由BSD 4.2版本最先推出,所以称作伯克利套接字/Socket,这些API被各大Unix操作系统厂商学习, 随后被移植到各大操作系统中,并成为了网络编程的事实标准。Socket 即套接字是指网络中 一种用来建立连接、网络通信的设备,用户创建了Socket之后,可以通过其发起或者接受TCP连接、可以向TCP的发送和接收缓冲区当中读写TCP数据段,或者发送UDP文本。
大端法和小端法
是两种定义了多字节数据
在计算机内存
中的存储顺序
。大端法/Big-Endian: 是指
高位字节
存储在内存的低地址
端,而低位字节存储在内存的高地址端。小端法/Little-Endian: 和大端法相反,
低位字节
存储在内存的低地址
端,高位字节存储在内存的高地址端。
而TCP/IP协议规定,当数据在网络中传输的时候,一律使用
网络字节序即大端法
。而"一般"主机比较多的用小端法存储数据(以x86,x64架构为例, 即大多数个人电脑和服务器,包括 Intel 和 AMD 的处理器)。Eg: 网络字节序
Eg: 以Linux为例我们可以同
lscpu
命令, 查看主机存储顺序。
根据上面所述: 网络字节序使用大端法, 主机字节序使用小端法, 我们需要对大小端进行转化。
在Linux中定义了相关转化的函数。( man htonl )
xxxxxxxxxx
912//convert values between host and network byte order
3uint32_t htonl(uint32_t hostlong);
4uint16_t htons(uint16_t hostshort);
5uint32_t ntohl(uint32_t netlong);
6uint16_t ntohs(uint16_t netshort);
7
8// uint32_t: 无符号 int
9// uint16_t: 无符号 short int
Eg: 代码示例
xxxxxxxxxx
2912// 高字节存低地址: 大端法
3// 低字节存低地址: 小端法
4// htonl: 主机->网络; 小端->大端
5// ntohl: 网络->主机; 大端->小端
6int main()
7{
8// int 4字节, 字节位从高到底分别为: 0x75, 0x76, 0x77, 0x78
9// 因为主机采用小端法, 低字节/低位 -> 低地址
10// 所以: 按照地址从低到高, 依次存储: 0x78, 0x77, 0x76, 0x75
11int num = 0x75767778;
12
13// &num: 取地址, 取的是首地址/低地址, 即0x78的地址
14// 把存储了0x78的地址, 用*c保存, *c指向了0x78
15char *c = (char *) #
16// 打印0x78, 按照字符打印; 我们知道0x78 对应的二进制为0111 1000 十进制即120, 而字符x所对应的ascii码值为120, 所以打印x
17printf("the first char : %c \n", *c);
18
19// htonl: 可以把主机顺序转化为网络序; 即小端转大端
20// 如果按照大端存储 0x75767778, 即高数字/字节位存小地址/低地址, 则按照地址从低到高, 依次存储0x75, 0x76, 0x77, 0x78
21int res = htonl(num);
22// &res, 代表数字的首地址/低地址, 而低地址的第一个字节存储的是 0x75
23// 等价于*c2, 指向0x75所在的字节
24char *c2 = (char *) &res;
25// 已知0x75的二进制为 0111 0101, 十进制为117, 117对应的ascii为u, 所以按照字符打印0x75,打印出来的是字符u
26printf("the first char : %c \n", *c2);
27
28return 0;
29}
在Socket编程中POSIX 套接字接口设计上提供了多个结构体, 以供我们适用在不同的情况。
比如
sockaddr
结构体, 这是一种通用的地址结构,它可以通用的描述IPv4和IPv6的结构,而且基本上所有涉及到地址的接口都使用了该类型作为参数。(比如: 上面addrinfo
结构体中,sockaddr *ai_addr
参数, 就使用sockaddr
类型 )但是由于它定义的过于通用, 它直接把一个具体的IP地址和端口信息混在一起, 使用起来过于麻烦; 我们需要更具体的IPV4和IPV6类型, 所以POSIX标准又更进一步的定义了
sockaddr_in
和sockaddr_in6
分别用于描述IPV4和IPV6类型。 并且, 在需要通用地址参数的函数调用中(例如,bind()
、connect()
、accept()
等, 他们需要sockaddr
类型的参数),我们可以直接将sockaddr_in
或sockaddr_in6
结构体的指针转换为sockaddr
类型使用, 这种转换是安全的。
在日常生活中我们更习惯与把IP地址书写成点分十进制, eg: 192.168.10.100...; 当我们需要通过Socket进行网络交互的时候, 我们怎么把它转化为合适的类型?
在POSIX 套接字接口设计上提供了结构体
in_addr
和in6_addr
, 分别用来存储IPv4和IPv6类型
的IP地址( man inet_aton)。以IPv4为例。(man 7 ip)xxxxxxxxxx
91struct sockaddr_in {
2sa_family_t sin_family; // 地址类型: AF_INET (IPv4)
3in_port_t sin_port; // 端口号: 注意in_port_t实际类型short int (网络字节序)
4struct in_addr sin_addr; // IP地址: internet address
5};
6
7struct in_addr {
8in_addr_t s_addr; // in_addr_t -> uint32_t -> 无符号int
9}
这也就意味着, 我们需要一套把
点分十进制
的IP地址, 转为无符号int
的手段。POSIX 套接字接口同时也设计了一套函数来实现该问题。(man inet_aton)xxxxxxxxxx
91234// 将一个点分十进制的IP地址字符串 -> 转换为网络字节序的32位整型数表示。
5in_addr_t inet_addr(const char *cp);
6// 将一个点分十进制的IP地址字符串 -> 转换为网络字节序的32位整型数表示。
7int inet_aton(const char *cp, struct in_addr *inp);
8// 将网络地址 -> 转换为点分十进制IP地址的字符串形式。
9char *inet_ntoa(struct in_addr in);
EgCode1:
xxxxxxxxxx
3012//使用inet_addr, 把点分十进制转化为网络字节序int(说白了就是把字符串直接转为二进制大端存储 )
3int main()
4{
5char *ip = "116.162.172.51";
6printf("ip: %s \n", ip);
7
8
9// 116 162 172 51
10// 0111 0100 1010 0010 1010 1100 0011 0011
11// inet_addr的作用是转化为网络字节序int 存储(即大端法: 高字节位存低地址)
12// 即:
13// 0111 0100 低位 高字节
14// 1010 0010
15// 1010 1100
16// 0011 0011 高位 低字节
17in_addr_t ip_int = inet_addr(ip);
18
19// 由于主机是小端存储, 主机认为按照上面内存排序的存储, 你这个数据真实应该是:
20// 0011 0011 1010 1100 1010 0010 0111 0100
21// 主机认为这四个字节存储的数值, 十进制是: 866951796
22printf("ip: %d \n", ip_int);
23
24// &ip_int 取首地址, 相当于拿到地位: 0111 0100的地址
25// 0111 0100 -> 十进制116 -> 转成字符 -> 对应ascii码为字符t
26// 将输出t
27char *chr = (char *)&ip_int;
28printf("char: %c \n", *chr);
29return 0;
30}
EgCode2:
xxxxxxxxxx
1512//使用inet_aton, 把点分十进制转化为网络字节序int
3int main()
4{
5char *ip = "116.162.172.51";
6printf("ip: %s \n", ip);
7
8struct in_addr inp;
9inet_aton(ip, &inp);
10printf("ip: %d \n", inp.s_addr );// ip: 866951796
11
12char *chr = (char *)&inp.s_addr;
13printf("char: %c \n", *chr);//char: t
14return 0;
15}
EgCode3:
xxxxxxxxxx
1312// 使用inet_ntoa:将网络IP -> 转化为点分十进制IP
3int main(int argc,char*argv[])
4{
5char *ip1 = "192.168.10.1";
6struct in_addr inp;
7inet_aton(ip1, &inp);//将字符串转化成网络IP
8
9char * ipStr = inet_ntoa(inp);//将网络IP转化成字符串
10printf("ip: %s \n", ipStr);// 输出: 192.169.10.1
11
12return 0;
13}
我们已知域名和IP地址的关系, 显然我们需要一种机制去建立域名和IP地址的映射关系, 一种方法是修改本机的hosts文件/etc/hosts,但是更加通用的方案是利用DNS协议,去访问一个DNS服务器,服务器当中存储了域名和IP 地址的映射关系。与这个操作相关的函数是
gethostbyname和getaddrinfo
。(man getaddrinfo) (gethostbyname我们认为属于过时状态, 有兴趣同学可以了解: man 3 gethostbyname )xxxxxxxxxx
11nslookup www.baidu.com
xxxxxxxxxx
281234// network address and service translation
5int getaddrinfo(
6const char *node, //主机名/域名/点分十进制IPv4/16进制IPv6
7const char *service,//服务名/端口(忽略:NULL)
8const struct addrinfo *hints,//设置期望返回的信息类型(忽略:NULL)
9struct addrinfo **res//获取符合条件的网址信息
10);
11
12struct addrinfo {
13int ai_flags; // 控制函数行为的标志位(忽略)
14int ai_family; // "指定地址族":AF_INET(IPv4), AF_INET6(IPv6), AF_UNSPEC(协议无关))
15int ai_socktype; // 套接字类型:SOCK_STREAM, SOCK_DGRAM(忽略)
16int ai_protocol; // 使用的协议(忽略)
17size_t ai_addrlen; // 地址的长度,以字节为单位(忽略)
18struct sockaddr *ai_addr; // "指向IP地址的指针"
19char *ai_canonname; // 主机的规范名称(忽略)
20struct addrinfo *ai_next; // "指向下一个结构体的指针"
21};
22
23指向地址的指针: (使用如下2.4章节)
24struct sockaddr{
25//...
26}
27
28指向下一个结构体的指针: (eg: getaddrinfo获得的是一个addrinfo链表, 会返回多个结果)
xxxxxxxxxx
11void freeaddrinfo(struct addrinfo *res);// addrinfo使用完毕后,应使用 freeaddrinfo 函数释放
EgCode:
xxxxxxxxxx
2612// 通过getaddrinfo: 把域名变成ip
3int main()
4{
5struct addrinfo *res;
6// DNS请求,获得结果
7getaddrinfo("cskaoyan.com", NULL, NULL, &res);
8//遍历res
9for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
10
11struct sockaddr *ipTag = p->ai_addr;
12
13if (p->ai_family == AF_INET) { // 判断获得的是否是IPv4地址
14// 类型转换
15struct sockaddr_in *socketTagV4 = (struct sockaddr_in *) ipTag;
16// 获取IP地址
17struct in_addr ipTagV4 = socketTagV4->sin_addr;
18// 把网路IP转成点分十进制字符串
19char * strIp = inet_ntoa(ipTagV4);
20printf("IPv4, ip = %s \n", strIp);
21}
22}
23// 释放
24freeaddrinfo(res);
25return 0;
26}
基于TCP通信的Socket基本流程:
初始化Socket: 调用Socket函数创建一个Socket通信端点 (参考: man 2 socket)
xxxxxxxxxx
9123//create an endpoint for communication
4int socket(
5int domain, // 协议:AF_INET (IPv4)、AF_INET6 (IPV6)....
6int type, // 套接字类型: SOCK_STREAM (TCP)、SOCK_DGRAM (UDP)....
7int protocol// 协议:IPPROTO_TCP (TCP)、IPPTOTO_UDP (UDP)...; 当protocol为0时,会自动选择type类型对应的默认协议。
8);
9// 返回值: 返回值是一个非负整数, 代表一个文件描述符,用于标识创建的套接字,并通过这个描述符进行后续的网络I/O操作。
实际上, socket函数本质是在内核态中创建了一个
对象
。这个函数虽然返回一个文件描述符来标识这个对象 但是它并不是通俗意义上的文件对象
.在这个socket对象中, 包含了进行网络通信所需要的各种信息和状态(Eg: 地址族/Address Family, 类型/Type, 协议/Protocol, 地址/Socket Address ...)。 除了这些信息以外, 这个对象中还维护了两个极其重要的缓冲区
输入缓冲区/SO_RCVBUF
和输出缓冲区/SO_SNDBUF
, 这两个缓冲区分别用于临时存储从网络接收的数据和待发送到网络的数据。
ps: 关于缓冲区的默认大小: (由 /proc/sys/net/core/rmem_default和/proc/sys/net/core/wmem_default文件指定)
绑定地址: 使用
bind函数
给socket端点
绑定端口和IP (函数参考: man 2 bind)xxxxxxxxxx
9123//bind a name to a socket
4int bind(
5int sockfd, // socket端点文件描述符
6const struct sockaddr *addr,// 要绑定的IP地址和端口号
7socklen_t addrlen // 指定的addr代表结构体长度,确保bind函数可以正确解析给定的地址信息:sizeod(addr)
8);
9//返回值: 成功时返回0。失败返回-1
const struct sockaddr *addr参数: 该参数用于提供给socket端点IP和端口信息, 但是
sockaddr
是一个通用的地址结构,实际使用的时候还是要使用sockaddr_in (IPv4)
,sockaddr_in6 (IPv6)
。在选择端口号设置时, 建议应当避开知名端口号的范围(<1024)。
使用bind 函数时要注意其地址是大端法描述的,可能需要执行强制类型转换。
IP设置:
xxxxxxxxxx
81当服务端设置监听IP地址时,对于IPv4,有几个特殊的IP地址可以使用:
2
30.0.0.0
4// 表示服务端愿意接受指向服务器主机的任何IP地址的连接。
5自己主机IP
6// 无需赘述, 最正常操作
7127.0.0.1
8// 这个地址用于测试和开发,仅允许接收来自本机的回环连接。
ps1: 一般我们都是给服务端
bind
, 那么客户端也可以bind
吗?
正常来讲客户端不需要bind; 客户端不bind操作系统都会分配一个临时的随机端口, 这已经足够使用了。
当然如果有特殊需求, 也可以对客户端进行bind, 用以指明发送和接收数据的IP和端口。
ps2: 服务端可不可以不bind?
如果服务端不进行bind操作, 一般操作系统都会分配一个临时的随机端口以供使用, 但是从逻辑上完全没有任何意义, 不允许这样操作。
设置监听: 使用
listen函数
对设置好端口和IP的服务端socket端点
监听外部连接请求 (函数参考: man 2 listen)xxxxxxxxxx
8123//listen for connections on a socket
4int listen(
5int sockfd, // socket端点文件描述符
6int backlog // 这个参数指定了套接字可以挂起的最大连接数
7);
8//返回值: 成功返回0, 失败返回-1
一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区(回收空间),转而在内核区维护两个队列结构: 半连接队列和全连接队列。
xxxxxxxxxx
21// 半连接队列用于管理成功第一次握手的连接
2// 全连接队列用于管理已经完成三次握手的队列。
backlog
参数实际上是用来设置Socket的ACCEPT队列/全连接队列的最大长度(在有的操作系统上backlog指的是半连接队列
和全连接队列
的长度之和) , 给一个一般性的正数即可。需要注意的是, 如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复,以方便客户端自动重传)
ps: 可以使用 netstat -an 命令可以查看主机上某个端口的监听情况 (eg: netstat -an|grep 12345)
建立连接: 使用
connect函数
使客户端
向服务器
发送建立连接请求,初始化一个连接 (man 2 connect)xxxxxxxxxx
9123//initiate a connection on a socket
4int connect(
5int sockfd, // socket端点文件描述符
6const struct sockaddr *addr,// 目标服务器的地址和端口信息
7socklen_t addrlen // 指定的addr代表结构体长度,确保bind函数可以正确解析给定的地址信息
8);
9// 返回值: 成功0, 失败-1
客户端在调用connect可以不使用bind来指定本地的端口信息,这客户端就会随机选择一个临时端口号来作为源端口。
调用connect预期是完成TCP建立连接的三次握手。 如果服务端未开启对应的端口号或者未监听,则只能收到一个RST回复,并且报错返回的内容 是"Connection refused"。
获取连接: 使用
accept函数
从服务端
的socket端点
的全连接队列中取出一个连接 (man 2 accept)xxxxxxxxxx
9123// accept a connection on a socket
4int accept(
5int sockfd, // socket端点文件描述符
6struct sockaddr *addr, // 用来获取连接对端/客户端的地址信息。如果不需要对端的地址信息, 可设参数为NULL
7socklen_t *addrlen // 用来获取addr结构体的大小。如果使用addr/非NULL,那么addrlen必须设置addr的大小/sizeof(addr); 如果addr是NULL,addrlen也必须是NULL。
8);
9// 返回值: 成功则返回一个新的套接字文件描述符,用于与客户端通信。失败返回-1。
需要特别注意的是, addrlen参数是一个传入传出参数,所以使用的时候(非NULL)需要主调函数提前分配好内存空间:sizeof(addr)
accept 函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接(三次握手)。如果全连接队列为空(没有新的客户端成功三次握手),那么accept会陷入阻塞。 一旦全连接队列中到来新的连接,此时accept操作就会就绪 (注意: 这种就绪是
读就绪
)。当accept执行完了之后,内核会创建一个新的套接字文件对象,该文件对象关联的文件描述符是accept的返回值,文件对象当中最重要的结构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。
ps: 注意区分两个套接字对象: 通过把旧的管理连接队列的套接字称作
监听套接字
,而新的用于发送和接收TCP段的套接字称作已连接套接字
。通常来说,监听套接字会一直存在,负责建立各个不同的TCP连接(只要源IP、源端口、目的IP、目的端口四元组任意一个 字段有区别,就是一个新的TCP连接),而某一条单独的TCP连接则是由其对应的已连接套接字
进行数据通信的。
发送和获取数据: 客户端OR服务端使用
Send和 Recv
用于发送和接收TCP数据。 (man 2 send) (man 2 recv)xxxxxxxxxx
10123// send a message on a socket
4ssize_t send(
5int sockfd, // socket端点文件描述符
6const void *buf, // 指向要发送数据的缓冲区的指针
7size_t len, // buf中数据的长度,以字节为单位
8int flags // 用于指定发送操作的额外选项: MSG_OOB(发送紧急数据)、MSG_DONTROUTE(不经过路由器直接发送到本地网络上的目的地)...大多数情况下,flags参数设置为0。
9);
10// 返回值: 成功返回实际发送的字节数。失败返回-1
xxxxxxxxxx
10123// receive a message from a socket
4ssize_t recv(
5int sockfd, // socket端点文件描述符
6void *buf, // 指向读出数据存放的缓冲区的指针
7size_t len, // buf的长度,以字节为单位
8int flags // 定接收行为的标志位:MSG_PEEK(查看数据但不从系统缓冲区中移除)、MSG_WAITALL(等待所有请求的数据才返回)...大多数情况下,flags设置为0。
9);
10// 返回值: 成功时返回实际读取的字节数。如果连接已经关闭返回0(对方close: 四次挥手)。读取失败返回-1
需要注意的是,
Send和 Recv
函数只是将数据在用户态空间和内核态的缓冲区之间进行传输。
send时
将数据拷贝到内核态并不意味着会马上传输,而是由操作系统
决定根据合适的时机, 再由内核协议栈按照协议的规范进行分节发送。(通常缓冲区如果数据过多会分节成 MSS的大小,然后根据窗口条件传输到网络层之中)对于发送和接收数据, 使用
Read和Write函数
可以实现同样的效果(本质是相同的),相当于flags 参数为0。注意:
xxxxxxxxxx
11需要特别注意的是, send和recv的次数和网络上传输的TCP数据段的数量没有关系,多次的send和recv可能只需要一次TCP段的传输。另外一方面, TCP是一种流式的通信协议,消息是以字节流的方式在信道中传输,这就意味着一个重要的事情, 消息和消息之间是没有 边界的。在不加额外约定的情况下,通信双方并不知道发送和接收到底有没有接收完一个消息,有可能多个消息会在一次传输中被发送和接收(江湖俗称"粘包"),也有有可能一个消息需要多个传输才能被完整的发送和接收(江湖俗称"半包")。
关闭连接:
客户端
或者服务器
使用close函数
关闭服务器. (man 2 close)xxxxxxxxxx
612// close a file descriptor
3int close(
4int fd // socket端点文件描述符
5);
6// 返回值: 关闭成功返回0, 失败返回-1
客户端
或者服务器
使用close函数
关闭连接的时候, 可能还有数据留在发送缓冲区中未被发送,close
操作会试图发送这些数据。
close函数
给连接的对端发送FIN包
用于断开连接的四次挥手, 等待连接的另一端也发送FIN包,并且本端回应ACK确认关闭。释放端口等资源。
EgCode
xxxxxxxxxx
5812
3int main()
4{
5char *sourceIP = "192.168.106.129";
6char *sourcePort = "8080";
7
8int socketFd = socket(AF_INET, SOCK_STREAM, 0);
9
10// 方式一: inet_addr
11// 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中
12//in_addr_t addrTIP = inet_addr(sourceIP);
13//struct in_addr inAddr;
14//inAddr.s_addr = addrTIP;
15
16// 方式二: inet_aton
17struct in_addr inAddr;
18inet_aton(sourceIP, &inAddr);
19
20// 把端口转为int类型
21int sourcePortInt = atoi(sourcePort);
22// 把端口号: 有主机字节序, 转为网络字节序
23int sourcePortNet = htons(sourcePortInt);
24
25// 构建"struct sockaddr"类型
26struct sockaddr_in socketAddr;
27socketAddr.sin_family = AF_INET;
28socketAddr.sin_addr = inAddr;
29socketAddr.sin_port = sourcePortNet;
30
31// 客户端向服务器发起建立连接请求
32int res_connect = connect(socketFd, (struct sockaddr *)&socketAddr, sizeof(socketAddr));
33ERROR_CHECK(res_connect, -1, "connect");
34
35//
36while(1){
37char buf[60] = {0};
38
39// 读取标准输入
40read(STDIN_FILENO, buf, sizeof(buf)-1);
41
42// 把标准输入, 发送给服务器
43int res_send = send(socketFd, buf, sizeof(buf), 0);
44ERROR_CHECK(res_send, -1, "send");
45
46char buf2[60] = {0};
47// 读取对方输入
48int res_recv = recv(socketFd, buf2, sizeof(buf2), 0);
49ERROR_CHECK(res_recv, -1, "res_recv");
50ERROR_CHECK(res_recv, 0, "other close");
51
52// 打印到标准输出
53write(STDOUT_FILENO, buf2, sizeof(buf2));
54}
55close(socketFd);
56
57return 0;
58}
EgCode
xxxxxxxxxx
6412
3int main()
4{
5char *sourceIP = "192.168.106.129";
6char *sourcePort = "8080";
7
8int socketFd = socket(AF_INET, SOCK_STREAM, 0);
9
10// 方式一: inet_addr
11// 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中
12//in_addr_t addrTIP = inet_addr(sourceIP);
13//struct in_addr inAddr;
14//inAddr.s_addr = addrTIP;
15
16// 方式二: inet_aton
17struct in_addr inAddr;
18inet_aton(sourceIP, &inAddr);
19
20// 把端口转为int类型
21int sourcePortInt = atoi(sourcePort);
22// 把端口号: 有主机字节序, 转为网络字节序
23int sourcePortNet = htons(sourcePortInt);
24
25
26// 构建"struct sockaddr"类型
27struct sockaddr_in socketAddr;
28socketAddr.sin_family = AF_INET;
29socketAddr.sin_addr = inAddr;
30socketAddr.sin_port = sourcePortNet;
31
32// bind:绑定端口
33int res_bind = bind(socketFd,(struct sockaddr *)&socketAddr, sizeof(socketAddr));
34ERROR_CHECK(res_bind, -1, "bind");
35
36// listen:监听端口
37listen(socketFd, 10);
38
39// accept: 获取连接
40int connectFd = accept(socketFd, NULL, NULL);
41
42while(1){
43
44char buf2[60] = {0};
45// 读取对方输入
46int res_recv = recv(connectFd, buf2, sizeof(buf2), 0);
47ERROR_CHECK(res_recv, -1, "recv");
48ERROR_CHECK(res_recv, 0, "other close");
49
50// 打印到标准输出
51write(STDOUT_FILENO, buf2, sizeof(buf2));
52
53char buf[60] = {0};
54// 读取标准输入
55read(STDIN_FILENO, buf, sizeof(buf)-1);
56
57// 把标准输入, 发送给服务器
58int res_send = send(connectFd, buf, sizeof(buf), 0);
59ERROR_CHECK(res_send, -1, "send");
60}
61close(socketFd);
62
63return 0;
64}
select函数
xxxxxxxxxx
18123int select(
4int maxfd, // 最大文件描述符的值加一
5fd_set *readset, // 结构中包含待检查是否有可读数据的文件描述符集合
6fd_set *writeset, // 结构中包含待检查是否可以非阻塞写入的文件描述符集合
7fd_set *exceptionset, // 结构中包含待检查是否有异常条件发生的文件描述符集合
8struct timeval * timeout // 表示select调用的最长等待时间
9);
10// 返回值:正数表示就绪的文件描述符数量, 0表示超时时间到了但没有文件描述符就绪, -1表示发生错误
11
12//集合的相关操作如下:
13void FD_ZERO(fd_set *fdset); // 将所有fd清零
14void FD_SET(int fd, fd_set *fdset); // 增加一个fd
15void FD_CLR(int fd, fd_set *fdset); // 删除一个fd
16int FD_ISSET(int fd, fd_set *fdset); // 检查fd是否在fdset中被标记为“就绪”; 不在集合中返回零, 在则非零。
17
18ps注意: 在调用select函数之后,文件描述符集合会被修,集合中只保留了那些已经就绪的文件描述符。所以每次调用select前都需要重新初始化和设置这些集合,除非你明确只关心一次select调用的结果。
xxxxxxxxxx
2012// 使用select监听标准输入
3int main(){
4fd_set read_fd_set;
5// init 文件描述符集合
6FD_ZERO(&read_fd_set);
7// 将stdin加入到集合中
8FD_SET(STDIN_FILENO, &read_fd_set);
9
10char buf[1024];
11while(1){
12int res = select(STDIN_FILENO+1, &read_fd_set,NULL,NULL,NULL);
13ERROR_CHECK(res, -1, "select");
14
15bzero(buf, sizeof(buf));
16read(STDIN_FILENO, buf, sizeof(buf));
17printf("input str = %s \n", buf);
18}
19return 0;
20}
EgCode: 客户端
xxxxxxxxxx
6812int main(){
3char *sourceIP = "192.168.106.129";
4char *sourcePort = "8080";
5
6int socketFd = socket(AF_INET, SOCK_STREAM, 0);
7
8// 方式一: inet_addr
9// 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中
10//in_addr_t addrTIP = inet_addr(sourceIP);
11//struct in_addr inAddr;
12//inAddr.s_addr = addrTIP;
13
14// 方式二: inet_aton
15struct in_addr inAddr;
16inet_aton(sourceIP, &inAddr);
17
18// 把端口转为int类型
19int sourcePortInt = atoi(sourcePort);
20// 把端口号: 有主机字节序, 转为网络字节序
21int sourcePortNet = htons(sourcePortInt);
22
23// 构建"struct sockaddr"类型
24struct sockaddr_in socketAddr;
25socketAddr.sin_family = AF_INET;
26socketAddr.sin_addr = inAddr;
27socketAddr.sin_port = sourcePortNet;
28
29// 客户端向服务器发起建立连接请求
30int res_connect = connect(socketFd, (struct sockaddr *)&socketAddr, sizeof(socketAddr));
31ERROR_CHECK(res_connect, -1, "connect");
32
33fd_set read_fd_set;
34while(1){
35FD_ZERO(&read_fd_set);
36FD_SET(socketFd, &read_fd_set);
37FD_SET(STDIN_FILENO, &read_fd_set);
38
39select(socketFd+1, &read_fd_set, NULL, NULL, NULL);
40
41if(FD_ISSET(STDIN_FILENO, &read_fd_set)){
42char buf[60] = {0};
43// 读取标准输入
44int res_read = read(STDIN_FILENO, buf, sizeof(buf)-1);
45if(res_read == 0){
46// 用户输入了EOF字符:在大多数UNIX和Linux系统上,EOF字符默认是Ctrl+D
47break;
48}
49// 把标准输入, 发送给服务器
50int res_send = send(socketFd, buf, sizeof(buf), 0);
51ERROR_CHECK(res_send, -1, "send");
52}
53if(FD_ISSET(socketFd, &read_fd_set)){
54char buf2[60] = {0};
55// 读取对方输入
56int res_recv = recv(socketFd, buf2, sizeof(buf2), 0);
57ERROR_CHECK(res_recv, -1, "res_recv");
58if(res_recv == 0){
59printf("对方结束 \n");
60break;
61}
62// 打印到标准输出
63write(STDOUT_FILENO, buf2, sizeof(buf2));
64}
65}
66close(socketFd);
67return 0;
68}
EgCode: 服务端
xxxxxxxxxx
7512int main(){
3char *sourceIP = "192.168.106.129";
4char *sourcePort = "8080";
5
6int socketFd = socket(AF_INET, SOCK_STREAM, 0);
7
8// 方式一: inet_addr
9// 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中
10//in_addr_t addrTIP = inet_addr(sourceIP);
11//struct in_addr inAddr;
12//inAddr.s_addr = addrTIP;
13
14// 方式二: inet_aton
15struct in_addr inAddr;
16inet_aton(sourceIP, &inAddr);
17
18// 把端口转为int类型
19int sourcePortInt = atoi(sourcePort);
20// 把端口号: 有主机字节序, 转为网络字节序
21int sourcePortNet = htons(sourcePortInt);
22
23// 构建"struct sockaddr"类型
24struct sockaddr_in socketAddr;
25socketAddr.sin_family = AF_INET;
26socketAddr.sin_addr = inAddr;
27socketAddr.sin_port = sourcePortNet;
28
29// bind:绑定端口
30int res_bind = bind(socketFd,(struct sockaddr *)&socketAddr, sizeof(socketAddr));
31ERROR_CHECK(res_bind, -1, "bind");
32
33// listen:监听端口
34listen(socketFd, 10);
35
36// accept: 获取连接
37int connectFd = accept(socketFd, NULL, NULL);
38
39fd_set read_fd_set;
40while(1){
41FD_ZERO(&read_fd_set);
42FD_SET(connectFd, &read_fd_set);
43FD_SET(STDIN_FILENO, &read_fd_set);
44select(connectFd+1, &read_fd_set, NULL, NULL, NULL);
45
46if(FD_ISSET(connectFd, &read_fd_set)){
47char buf2[60] = {0};
48// 读取对方输入
49int res_recv = recv(connectFd, buf2, sizeof(buf2), 0);
50ERROR_CHECK(res_recv, -1, "recv");
51// 判断对方是否已经关闭连接
52if(res_recv == 0){
53printf("对方结束 \n");
54break;
55}
56// 打印到标准输出
57write(STDOUT_FILENO, buf2, sizeof(buf2));
58}
59if(FD_ISSET(STDIN_FILENO, &read_fd_set )){
60char buf[60] = {0};
61// 读取标准输入
62int res_read = read(STDIN_FILENO, buf, sizeof(buf)-1);
63// 用户输入了EOF字符:在大多数UNIX和Linux系统上,EOF字符默认是Ctrl+D
64if(res_read == 0){
65break;
66}
67// 把标准输入, 发送给服务器
68int res_send = send(connectFd, buf, sizeof(buf), 0);
69ERROR_CHECK(res_send, -1, "send");
70}
71}
72close(connectFd);
73close(socketFd);
74return 0;
75}
当我们关闭正在运行的服务端之后, 在短时间内尝试重启服务端有可能失败, 这个错误发生的原因是当重启服务端时, 服务端在尝试bind一个网络地址(IP 地址和
端口号
)到套接字上时,端口号
已经被另一个套接字占用。如图:xxxxxxxxxx
31// 在上一个TCP连接被关闭后,该连接的端口会进入TIME_WAIT状态。TIME_WAIT状态持续2倍的最大报文段生存时间/MSL/Maximum Segment Lifetime,
2// 在这段时间内,即使原来的连接已经关闭,操作系统仍然保留着连接使用的端口号,不允许其他进程绑定到同一个地址和端口上。
3// 当新启动的服务端, 在尝试bind同一个未被释放的端口时,显示`端口号已经被占用`
而在实际工作当中, TIME_WAIT状态的存在虽然有可能会提高连接的可靠性,但是一个服务端当中假如存在大量的TIME_WAIT状态,那么服务端的工作能力会极大地受到限制,而取消TIME_WAIT状态其实对可靠性的影响比较小,所以用户可以选择使用
setsockopt 函数
修改监听套接字的属性,使其可以在TIME_WAIT状态下依然可以bind重复的地址, 重新接收用户端握手请求。 (man 2 setsockopt)xxxxxxxxxx
13123//set options on sockets
4int setsockopt(
5int sockfd, // 套接字文件描述符
6int level, // 参数指定控制选项的代码层: SOL_SOCKET/套接字层, IPPROTO_TCP, IPPROTO_IP ....
7int optname, // 设置socket的那个行为: SO_REUSEADDR/重新绑定正在使用中或等待关闭的地址
8// SO_KEEPALIVE/保持连接活跃,检测长时间无数据交换的连接
9// ...(后面还有一些其它项)
10const void *optval, // 指向包含新选项值的缓冲区的指针。对于布尔SO_REUSEADDR,非零值表示启用,零值表示禁用。
11socklen_t optlen // optval缓冲区的长度, 确保函数能正确解释optval指向的值。
12);
13// 返回值: 成功返回0, 失败返回-1。
ps:
setsockopt 函数
需要在bind函数
之前执行, socket函数之后
EgCode:
xxxxxxxxxx
61socket(...)
2// ...
3int reuse = 1;
4setsockopt(socketFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
5// ...
6bind(...);
客户端和服务器之间的连接可能由于各种原因断开。为了维持一个持久的会话和提供不间断的服务体验,当客户端软件尝试重新建立连接的时候。服务器应该启动重连机制,重新连接服务器。我们可以通过Select和Socket模拟这个过程。
xxxxxxxxxx
31// 每次重新调用select之前应该要重置文件描述符, 因为在调用select函数之后,文件描述符集合会被修,集合中只保留了那些已经就绪的文件描述符。
2// select的第一个参数应当足够大,从而避免无法监听到新的已连接套接字的文件描述符。
3// 从socket的全连接队列中获取一个新连接,即accept。它本质上是一种读就绪, 可用Select监听Socket。
EgCode:
xxxxxxxxxx
9712int main(){
3char *sourceIP = "192.168.106.129";
4char *sourcePort = "8080";
5
6int socketFd = socket(AF_INET, SOCK_STREAM, 0);
7
8// 方式一: inet_addr
9// 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中
10//in_addr_t addrTIP = inet_addr(sourceIP);
11//struct in_addr inAddr;
12//inAddr.s_addr = addrTIP;
13
14// 方式二: inet_aton
15struct in_addr inAddr;
16inet_aton(sourceIP, &inAddr);
17
18// 把端口转为int类型
19int sourcePortInt = atoi(sourcePort);
20// 把端口号: 有主机字节序, 转为网络字节序
21int sourcePortNet = htons(sourcePortInt);
22
23// 构建"struct sockaddr"类型
24struct sockaddr_in socketAddr;
25socketAddr.sin_family = AF_INET;
26socketAddr.sin_addr = inAddr;
27socketAddr.sin_port = sourcePortNet;
28
29int reuse = 1;
30setsockopt(socketFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
31
32// bind:绑定端口
33int res_bind = bind(socketFd,(struct sockaddr *)&socketAddr, sizeof(socketAddr));
34ERROR_CHECK(res_bind, -1, "bind");
35
36// listen:监听端口
37listen(socketFd, 10);
38
39int connectFd;
40
41fd_set read_fd_set_base;
42FD_ZERO(&read_fd_set_base);
43FD_SET(socketFd, &read_fd_set_base);
44FD_SET(STDIN_FILENO, &read_fd_set_base);
45
46while(1){
47fd_set read_while;
48memcpy(&read_while, &read_fd_set_base, sizeof(read_fd_set_base));
49
50select(10, &read_while, NULL, NULL, NULL);
51
52if(FD_ISSET(connectFd, &read_while)){
53char buf2[60] = {0};
54// 读取对方输入
55int res_recv = recv(connectFd, buf2, sizeof(buf2), 0);
56ERROR_CHECK(res_recv, -1, "recv");
57// 判断对方是否已经关闭连接
58if(res_recv == 0){
59printf("--连接断开-- \n");
60// 增加新连接监听; 去除连接和标准输入监听
61FD_CLR(connectFd, &read_fd_set_base);
62FD_CLR(STDIN_FILENO, &read_fd_set_base);
63FD_SET(socketFd, &read_fd_set_base);
64close(connectFd);
65continue;
66}
67
68// 打印到标准输出
69write(STDOUT_FILENO, buf2, sizeof(buf2));
70}
71if(FD_ISSET(socketFd, &read_while)){
72// accept: 获取连接
73connectFd = accept(socketFd, NULL, NULL);
74// 增加连接和标准输入监听; 去除新连接监听
75FD_SET(connectFd, &read_fd_set_base);
76FD_SET(STDIN_FILENO, &read_fd_set_base);
77FD_CLR(socketFd, &read_fd_set_base);
78printf("--对方上线-- \n");
79}
80if(FD_ISSET(STDIN_FILENO, &read_while)){
81char buf[60] = {0};
82// 读取标准输入
83int res_read = read(STDIN_FILENO, buf, sizeof(buf)-1);
84// 用户输入了EOF字符:在大多数UNIX和Linux系统上,EOF字符默认是Ctrl+D
85if(res_read == 0){
86printf("服务端: 断开连接 \n");
87break;
88}
89// 把标准输入, 发送给服务器
90int res_send = send(connectFd, buf, sizeof(buf), 0);
91ERROR_CHECK(res_send, -1, "send");
92}
93}
94close(connectFd);
95close(socketFd);
96return 0;
97}
tcpdump: connect时可以使用tcpdump命令可以查看包的状态
解决网络问题的一般流程
xxxxxxxxxx
51观察连接的状态
2// ss -tn
3// netstat -tn
4tcpdump 保存抓包数据 (用wireshark打开抓包数据分析)
5// sudo tcpdump -w a.txt
利用半连接队列的设计思路,网络攻击者想到了一种恶意攻击的方法。他们伪造一些SYN请 求但是并不打算建立连接,这些请求的源地址随机构建的,或者是感染其他计算机来发起请求,服务端内核就会维持一个很大的队列来管理这些半连接。当半连接足够多的时候,就会导致新来的正常连接请求得不到响应, 也就是所谓的DDOS攻击。
当然也可以通过减SYN+ACK重传次数、增加半连接队列长度、 启用syncookie 等手段防御DDOS,不过在高强度攻击面前,调整tcp_syn_retries 和 tcp_max_syn_backlog并不能解决根本问题。更有效的防御手段是激活tcp_syncookies — —在连接真正创建起来之前,它并不会立刻给请求分配数据区存储连接状态,而是通过构建一个带签名的序号来屏蔽伪造请求。
基于UDP通信的Socket基本流程:
函数定义和使用同TCP(
3.1.1
)。Socket函数的type: SOCK_STREAM (TCP)、SOCK_DGRAM (UDP)。
函数定义和使用同TCP(
3.1.2
)。
发送和获取数据: 客户端OR服务端使用
Sendto和 Recvfrom
用于发送和接收UDP数据。 (man 2 sendto) (man 2 recvfrom)xxxxxxxxxx
12123// send a message on a socket
4ssize_t sendto(
5int sockfd, // socket端点文件描述符
6const void *buf,// 指向要发送数据的缓冲区的指针
7size_t len, // buf中数据的长度,以字节为单位
8int flags, // 发送操作的额外选项: 紧急发送、直发...大多数情况下,flags参数设置为0。
9const struct sockaddr *dest_addr, // 要发送的目的地址的IP地址和端口号
10socklen_t addrlen // 指定的dest_addr代表结构体长度,确保bind函数可以正确解析给定的地址信息:sizeod(dest_addr)
11);
12// 返回值: 成功时返回实际发送的字节数。这个数值可能会小于在length参数中指定的数值,表示只有部分数据被发送。失败时,返回-1。
xxxxxxxxxx
12123//receive a message from a socket
4ssize_t recvfrom(
5int sockfd, // socket端点文件描述符
6void *buf, // 指向读出数据存放的缓冲区的指针
7size_t len, // buf中数据的长度,以字节为单位
8int flags, // 接收行为的标志位, 默认0
9struct sockaddr *src_addr, // 用于存储发送方的地址信息
10socklen_t *addrlen // 指定的src_addr代表结构体长度,确保bind函数可以正确解析给定的地址信息: &sizeod(src_addr)
11);
12// 返回值: 成功时,返回接收到的字节数; 失败时返回-1。
和基于
TCP
的send和recv函数
不同的是, 基于UDP
的sendto和recvfrom函数
携带了地址信息, 用于确定目的地址和获取接收的信息的来源地址。这也就意味着, 在使用UDP进行无连接的通信时, 因为没有建立连接的过程,所以必须总是由客户端先调用sendto发送消息给服务端,这样服务端才能知道对端的地址信息,从进入后续的通信。
在使用UDP进行无连接的通信时, 因为是无连接的, 所以客户端或服务端关闭, 对方无法直接感知。
需要注意的是类型和参数: socklen_t *addrlen (非int) (socklen_t 本身是unsigned int)
函数定义和使用同TCP(
3.1.7
)。
EgCode:
xxxxxxxxxx
3912int main(){
3char *ip = "192.168.106.129";
4char *port = "8080";
5
6struct sockaddr_in socket_addr;
7socket_addr.sin_family = AF_INET;
8// 字符串-> int -> 大端
9socket_addr.sin_port = htons(atoi(port));
10// 点分十进制 -> 大端ip
11socket_addr.sin_addr.s_addr = inet_addr(ip);
12
13int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
14
15fd_set read_set_base;
16while(1){
17FD_ZERO(&read_set_base);
18FD_SET(STDIN_FILENO, &read_set_base);
19FD_SET(socket_fd, &read_set_base);
20
21select(socket_fd+1, &read_set_base, NULL, NULL, NULL);
22if(FD_ISSET(STDIN_FILENO, &read_set_base)){
23char buf[60] = {0};
24int res_read = read(STDIN_FILENO, buf, sizeof(buf));
25
26int res_send = sendto(socket_fd, buf, sizeof(buf),0,(struct sockaddr *) &socket_addr, sizeof(socket_addr));
27ERROR_CHECK(res_send, -1, "send");
28}
29if(FD_ISSET(socket_fd, &read_set_base)){
30char buf[60] = {0};
31struct sockaddr_in recv_addr;
32socklen_t len = sizeof(recv_addr);
33int res_recvfrom = recvfrom(socket_fd,buf,sizeof(buf),0,(struct sockaddr *)&recv_addr,&len);
34printf("from %s: %s \n",inet_ntoa(recv_addr.sin_addr), buf);
35}
36}
37close(socket_fd);
38return 0;
39}
EgCode:
xxxxxxxxxx
4212int main(){
3char *ip = "192.168.106.129";
4char *port = "8080";
5struct sockaddr_in socket_addr;
6socket_addr.sin_family = AF_INET;
7socket_addr.sin_port = htons(atoi(port));
8socket_addr.sin_addr.s_addr = inet_addr(ip);
9
10int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
11bind(socket_fd,(struct sockaddr *)&socket_addr, sizeof(socket_addr));
12
13struct sockaddr_in client_addr;
14memset(&client_addr, 0, sizeof(client_addr));
15fd_set read_set_base;
16while(1){
17FD_ZERO(&read_set_base);
18FD_SET(STDIN_FILENO, &read_set_base);
19FD_SET(socket_fd, &read_set_base);
20
21select(socket_fd+1, &read_set_base, NULL, NULL, NULL);
22
23if(FD_ISSET(STDIN_FILENO, &read_set_base)){
24char buf[60] = {0};
25read(STDIN_FILENO, buf, sizeof(buf));
26if(!client_addr.sin_port){
27printf("暂时未知客户端,等待客户端连接重新输入 \n");
28}else{
29int res_send = sendto(socket_fd, buf, sizeof(buf),0,(struct sockaddr *)&client_addr,sizeof(client_addr));
30printf("res_send: %d \n", res_send);
31}
32}
33if(FD_ISSET(socket_fd, &read_set_base)){
34char buf[60] = {0};
35socklen_t len = sizeof(client_addr);
36recvfrom(socket_fd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len);
37printf("from %s:%d 的数据: %s \n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port), buf);
38}
39}
40close(socket_fd);
41return 0;
42}
模拟群聊实现
xxxxxxxxxx
5912// 聊天室客户端
3int main(int argc,char*argv[])
4{
5// 启动socket函数, 获得对应的socket_fd描述符
6int sofd = socket(AF_INET, SOCK_STREAM, 0);
7
8// 创建服务端的地址和端口
9struct sockaddr_in sockaddr;
10sockaddr.sin_family = AF_INET;
11sockaddr.sin_addr.s_addr = inet_addr("192.168.106.129");
12sockaddr.sin_port = htons(atoi("8080"));
13
14// 建立socket链接
15connect(sofd, (struct sockaddr *)&sockaddr, sizeof(sockaddr));
16
17// 构建select监听的集合: fd_set类型
18fd_set set;
19// 清空set: 初始化set
20FD_ZERO(&set);
21
22while(1){
23// 添加socket监听
24FD_SET(sofd, &set);
25// 添加标准输入监听集合
26FD_SET(STDIN_FILENO, &set);
27// select开始监听有没有就绪事件
28select(10, &set, NULL, NULL, NULL);
29
30// 判断标准输入是否就绪
31if(FD_ISSET(STDIN_FILENO, &set)){
32// 读取标准输入
33char buf[60] = {0};
34int res_read = read(STDIN_FILENO, buf, sizeof(buf));
35// 用户输入EOF(Ctrl+D), 准备挂断链接
36if(res_read == 0){
37printf("已退出群聊 \n");
38break;
39}
40// 把读取的标准输入, 发送给服务器
41send(sofd, buf, sizeof(buf), 0);
42}
43// 判断是否有socket数据达到:即socket就绪
44if(FD_ISSET(sofd, &set)){
45char buf[60] = {0};
46// 从socket的缓冲区中, 读取到达数据
47int res_recv = recv(sofd, buf, sizeof(buf), 0);
48// 如果recv返回值为0, 表示对方断开链接
49if(res_recv == 0){
50printf("--服务器断开链接OR服务端踢出链接------");
51break;
52}
53// 打印读取的数据到控制台
54printf("msg: %s \n", buf);
55}
56}
57close(sofd);
58return 0;
59}
xxxxxxxxxx
9312// 聊天室服务端
3typedef struct conn_s{
4int netfd;
5int isalive;
6} conn_t;
7
8int main(int argc,char*argv[])
9{
10// 启动socket函数, 获得对应的socket_fd描述符
11int sofd = socket(AF_INET, SOCK_STREAM, 0);
12
13// 创建服务端的地址和端口
14struct sockaddr_in sockaddr;
15sockaddr.sin_family = AF_INET;
16sockaddr.sin_addr.s_addr = inet_addr("192.168.106.129");
17sockaddr.sin_port = htons(atoi("8080"));
18
19// 绑定端口ip
20bind(sofd, (struct sockaddr *)&sockaddr, sizeof(sockaddr));
21// 监听端口
22listen(sofd, 10);
23
24// 构建select监听的集合: fd_set类型
25fd_set set;
26// 清空set: 初始化set
27FD_ZERO(&set);
28FD_SET(sofd, &set);
29
30// 客户端数组: 用于保存所有链接服务器的客户端
31conn_t list[1024] ;
32memset(list, 0 , sizeof(list));
33// 客户端的存在个数
34int index;
35
36while(1){
37// 构建此次的监听集合
38fd_set temp_set;
39// 把set记录复制到本次监听集合
40memcpy(&temp_set, &set, sizeof(set));
41
42// select开始监听有没有就绪事件
43select(10, &temp_set, NULL, NULL, NULL);
44
45// 判断是否有socket数据达到:即socket就绪
46if(FD_ISSET(sofd, &temp_set)){
47// 获得一个新的客户端链接
48int netfd = accept(sofd, NULL, NULL);
49// 保存到""客户端""链接数组中
50list[index].isalive = 1;
51list[index].netfd = netfd;
52// 下一次增加监听这个客户端
53FD_SET(netfd, &set);
54
55index++;
56}
57// 遍历客户端列表
58for(int i=0; i<index; i++){
59// 当前遍历的客户端
60conn_t con = list[i];
61// 判断这个客户端是否存活, 以及是否就绪: 即是否有消息到来
62if(con.isalive == 1 && FD_ISSET(con.netfd, &temp_set)){
63// 该客户端存活, 且处于就绪状态
64// 读取客户端的到达信息
65char buf[60] = {0};
66int res_recv = recv(con.netfd, buf, sizeof(buf), 0);
67// 如果返回值为0, 说明这个客户端已经断开
68if(res_recv == 0){
69// 把客户端数组中该标记置为0: 即非存活状态
70list[i].isalive = 0;
71// 取消后续对次客户端的select监控
72FD_CLR(con.netfd, &set);
73// 关闭这个客户端
74close(list[i].netfd);
75}else{
76// 把读取到的数据, 分发给别的客户端
77// 遍历所有客户端
78for(int j=0; j<index; j++){
79if(list[j].isalive == 0|| j == i){
80// 跳过断开链接的客户端, 跳过发信息过来的客户端
81continue;
82}
83// 发送信息
84send(list[j].netfd, buf, sizeof(buf), 0);
85}
86}
87}
88
89}
90}
91close(sofd);
92return 0;
93}
超时踢出群聊: 10s
xxxxxxxxxx
11612// 聊天室服务端: 沉默超过10秒踢出
3typedef struct conn_s{
4int netfd;
5int isalive;
6time_t tagActiveTime;
7} conn_t;
8
9int main(int argc,char*argv[])
10{
11// 启动socket函数, 获得对应的socket_fd描述符
12int sofd = socket(AF_INET, SOCK_STREAM, 0);
13
14// 创建服务端的地址和端口
15struct sockaddr_in sockaddr;
16sockaddr.sin_family = AF_INET;
17sockaddr.sin_addr.s_addr = inet_addr("192.168.106.129");
18sockaddr.sin_port = htons(atoi("8080"));
19
20// 绑定端口ip
21bind(sofd, (struct sockaddr *)&sockaddr, sizeof(sockaddr));
22// 监听端口
23listen(sofd, 10);
24
25// 构建select监听的集合: fd_set类型
26fd_set set;
27// 清空set: 初始化set
28FD_ZERO(&set);
29FD_SET(sofd, &set);
30
31// 客户端数组: 用于保存所有链接服务器的客户端
32conn_t list[1024] ;
33memset(list, 0 , sizeof(list));
34// 客户端的存在个数
35int index;
36
37while(1){
38// 构建此次的监听集合
39fd_set temp_set;
40// 把set记录复制到本次监听集合
41memcpy(&temp_set, &set, sizeof(set));
42
43// select开始监听有没有就绪事件
44struct timeval timev;
45timev.tv_sec = 1;
46timev.tv_usec = 0;
47select(100, &temp_set, NULL, NULL, &timev);
48
49// 判断是否有socket数据达到:即socket就绪
50if(FD_ISSET(sofd, &temp_set)){
51// 获得一个新的客户端链接
52int netfd = accept(sofd, NULL, NULL);
53// 保存到""客户端""链接数组中
54list[index].isalive = 1;
55list[index].netfd = netfd;
56//list[index].tagActiveTime = time(NULL);
57time(&list[index].tagActiveTime);
58// 下一次增加监听这个客户端
59FD_SET(netfd, &set);
60
61index++;
62}
63// 遍历客户端列表
64for(int i=0; i<index; i++){
65// 当前遍历的客户端
66conn_t con = list[i];
67// 判断这个客户端是否存活, 以及是否就绪: 即是否有消息到来
68if(con.isalive == 1 && FD_ISSET(con.netfd, &temp_set)){
69// 该客户端存活, 且处于就绪状态
70// 读取客户端的到达信息
71char buf[60] = {0};
72int res_recv = recv(con.netfd, buf, sizeof(buf), 0);
73// 如果返回值为0, 说明这个客户端已经断开
74if(res_recv == 0){
75// 把客户端数组中该标记置为0: 即非存活状态
76list[i].isalive = 0;
77// 取消后续对次客户端的select监控
78FD_CLR(con.netfd, &set);
79// 关闭这个客户端
80close(list[i].netfd);
81}else{
82// 把读取到的数据, 分发给别的客户端
83// 遍历所有客户端
84for(int j=0; j<index; j++){
85if(list[j].isalive == 0|| j == i){
86// 跳过断开链接的客户端, 跳过发信息过来的客户端
87continue;
88}
89// 发送信息
90send(list[j].netfd, buf, sizeof(buf), 0);
91}
92// 更新这个客户端的活跃时间标记
93// list[i].tagActiveTime = time(NULL);
94time(&list[i].tagActiveTime);
95}
96}
97}
98// 判断那个活跃的链接, 已经沉默超过5秒了
99for(int i=0; i<index; i++){
100// 获得当前时间
101time_t now;
102time(&now);
103// 判断活跃时间是否超过10秒
104if(list[i].isalive == 1 && now - list[i].tagActiveTime > 10){
105// 踢出链接
106close(list[i].netfd);
107// 标记不活跃
108list[i].isalive = 0;
109// 下次不在监听
110FD_CLR(list[i].netfd, &set);
111}
112}
113}
114close(sofd);
115return 0;
116}
IO多路复用: select epoll
epoll: 监听集合和就绪集合分离: 不需要重置监听集合, 监听集合不需要再用户态和内核态来回拷贝
epoll: 没有1024的文件描述符监听的大小限制
epoll: 不像select需要反复调用cpu一直轮询是否就绪, 只是那个文件状态变化被动通知epoll, epoll再判断
效率差异: epoll效率更高, select效率略低
我们在前面实现I/O多路复用的时候, 选择通过
select函数
监听文件描述符的方式,以便知道它们在何时变为可读/都就绪或者可写/写就绪状态。这使得程序能够在单个线程或进程中同时管理多个I/O操作,而无需为每个I/O操作分别使用阻塞调用或者为每个操作分配独立的线程或进程。但是, 在面临大量文件描述符或要求更高性能的场合时,我们可能需要考虑更好的替代方案。
这是因为select在使用的时候, 有一些明显的缺点:
select的最大限制是它支持的文件描述符数量有限(
fd_set大小1024
), 对于大型服务器来说可能不够用。每次调用select 时,都需要把整个
文件描述符集合
从用户空间/用户态
复制到内核空间/内核态
,当在内核态检查完就绪, 又需要把就绪描述符集合
从内核空间/内核态
复制到用户空间/用户态
。 随着监听集合大小增加,这个操作变得越来越低效。select函数使用的时候,每次调用完select都需要重新设置
文件描述符集合
,这是因为在select中文件描述符集合
的输入输出未分离,返回的fd_set会将未就绪的文件描述符
清空。....
为了解决select函数的一些缺点,Linux内核提供了一种具有更好的扩展性和性能的I/O多路复用技术,即
epoll
。尤其是在处理大量并发网络连接时,epoll
是专门为高性能网络服务器设计的,可以有效地管理成千上万的并发连接。(需要注意的是epoll
属于Linux内核提供I/O多路复用技术, 在Windows或者MAC系统上, 我们我们可以使用其对应IOCP或者Kqueue)
epoll具有以下显著的特点:
相比较select而言, epoll没有对文件描述符数量进行限制。(它会随着内存的增大而导致上限变化, 可以参考: /proc/sys/fs/file-max )
epoll函数创建的
epoll文件对象
常驻内核态; 并且在该对象内部实现中把监听集合
和就绪集合
拆开维护。其中监听集合使用红黑树, 在管理大量
的文件描述符, 可以做到高效的查找、添加和删除对应的文件描述符; 就绪集合使用线性表。xxxxxxxxxx
11监听事件集合用来存储所有需要关注的设备(即文件描述符)和对应操作(比如读、写、挂起和异常等等),当监听的设备有事件产生时,比如网卡上接收到了数据并传输到了缓冲区当中时,硬件会采用中断等方式通知操作系统,操作系统会将就绪事件拷贝到就绪事件队列中,并且找到阻塞在epoll_wait的线程,让其就绪。
在内核态
epoll
只检测那些变为活跃状态的文件描述符就绪与否, 而不是像select一样对需要监听的文件描述符集合进行轮询检测。显著减少CPU的负担,提高应用程序的性能。(基于epoll的“回调机制”, epoll注册的文件描述符,其自身状态发生变换, 内核会触发epoll为其预设的“回调函数,” 通知epoll其处于活跃状态)当应用程序调用
epoll_wait
时,内核会检查这个就绪列表,然后将就绪列表
复制到用户空间提供的缓冲区中,以通知应用程序哪些文件描述符上的事件已经就绪。并且在这个过程中, 原文件监听集合
是没有被修改的, 也就意味着它不需要像select一样反复初始化监听集合, 不需要像select一样每次调用完select都重新设置文件描述符集合
。...
(man 7 epoll)
创建epoll对象: (man 2 epoll_create)
xxxxxxxxxx
612// open an epoll file descriptor
3int epoll_create(
4int size // 历史遗留参数, 已无任何意义, 大于0即可
5);
6// 返回值: 成功返回epoll文件对象的文件描述符, 失败返回-1
调整监听事件集合: (man 2 epoll_ctl )
xxxxxxxxxx
912// control interface for an epoll file descriptor
3int epoll_ctl(
4int epfd, // epoll的文件描述符
5int op, // 操作类型:EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL, 分别表示添加、修改和删除事件
6int fd, // 要被监听对应操作的文件描述符
7struct epoll_event *event // 指定对监听的文件描述符的监听事件。只有在添加、修改文件描述符时,这个参数才是必需的;在删除操作时,通常设置为NULL
8);
9// 返回值:
xxxxxxxxxx
61// (man 2 epoll_ctl )
2
3struct epoll_event {
4uint32_t events; // 事件: EPOLLIN/读取操作、EPOLLOUT/写入操作、...、EPOLLET/边缘触发模式 (其他不重要)
5epoll_data_t data; // 上述事件的对应的文件描述符
6};
xxxxxxxxxx
81// (man 2 epoll_ctl )
2
3typedef union epoll_data {
4void *ptr;
5int fd; // 文件描述符
6uint32_t u32;
7uint64_t u64;
8} epoll_data_t;
进入阻塞状态,直到监听的设备就绪或者超时: ( man 2 epoll_wait )
xxxxxxxxxx
912// wait for an I/O event on an epoll file descriptor
3int epoll_wait(
4int epfd, // epoll的文件描述符
5struct epoll_event *events, // 用于接收就绪集合的数组
6int maxevents, // 最大就绪集合长度
7int timeout // 超时时间(毫秒), -1则一直等待
8);
9// 返回值: 成功返回就绪个数, 失败返回-1
客户端:
xxxxxxxxxx
6012int main()
3{
4// 创建socket
5int sofd = socket(AF_INET, SOCK_STREAM, 0);
6// 端口IP
7char *ip = "192.168.106.129";
8char *port = "8080";
9// 构建sockaddr
10struct sockaddr_in sockaddr;
11sockaddr.sin_addr.s_addr = inet_addr(ip);
12sockaddr.sin_family = AF_INET;
13sockaddr.sin_port = htons(atoi(port));
14// 建立socket连接
15connect(sofd, (struct sockaddr *)&sockaddr, sizeof(sockaddr));
16// 创建epoll文件对象
17int epollfd = epoll_create(1);
18// 添加监听事件: 标准输入
19struct epoll_event event;
20event.events = EPOLLIN;
21event.data.fd = STDIN_FILENO;
22epoll_ctl(epollfd, EPOLL_CTL_ADD, STDIN_FILENO, &event );
23// 添加监听事件: socket的接收数据(读就绪)
24event.data.fd = sofd;
25epoll_ctl(epollfd, EPOLL_CTL_ADD, sofd, &event );
26
27while(1){
28// 构建就绪接收数组
29struct epoll_event events[10];
30// 开始监听
31int num = epoll_wait(epollfd, events, 10, -1);
32// 遍历就绪集合
33for(int i = 0; i < num; i++){
34// 就绪fd
35int cfd = events[i].data.fd;
36if(cfd == STDIN_FILENO){
37// 标准输入就绪
38char buf[60] = {0};
39// 读标准输入
40read(STDIN_FILENO, buf, sizeof(buf));
41// 发送对端
42send(sofd, buf, strlen(buf), 0);
43}else if(cfd = sofd){
44// 对端数据到达, socket读就绪
45char buf[60] = {0};
46//读数据
47int res_recv = recv(sofd, buf, sizeof(buf), 0);
48if(res_recv == 0){
49printf("对方断开 \n");
50goto end;
51}
52write(STDOUT_FILENO, buf, sizeof(buf));
53}
54}
55
56}
57end:
58close(sofd);
59return 0;
60}
服务端:
xxxxxxxxxx
6312int main()
3{
4// 创建socket
5int sofd = socket(AF_INET, SOCK_STREAM, 0);
6// 端口IP
7char *ip = "192.168.106.129";
8char *port = "8080";
9// 构建sockaddr
10struct sockaddr_in sockaddr;
11sockaddr.sin_addr.s_addr = inet_addr(ip);
12sockaddr.sin_port = htons(atoi(port));
13sockaddr.sin_family = AF_INET;
14// 绑定端口
15bind(sofd, (struct sockaddr *)&sockaddr, sizeof(sockaddr));
16// 监听请求
17listen(sofd, 100);
18// 获取连接
19int netfd = accept(sofd, NULL, 0);
20
21// 创建epoll对象
22int epollfd = epoll_create(1);
23// 设计监听集合
24struct epoll_event event;
25event.events = EPOLLIN;
26event.data.fd = netfd;
27epoll_ctl(epollfd, EPOLL_CTL_ADD, netfd, &event);
28
29event.data.fd = STDIN_FILENO;
30epoll_ctl(epollfd, EPOLL_CTL_ADD, STDIN_FILENO, &event);
31
32while(1){
33// 构建就绪接收数组
34struct epoll_event events[2];
35// 监听
36int num = epoll_wait(epollfd, events, 2, 0);
37// 遍历就绪集合
38for(int i = 0; i < num; i++){
39int cfd = events[i].data.fd;
40if(cfd == STDIN_FILENO){
41//读取标准输入
42char buf[60] = {0};
43read(STDIN_FILENO, buf, sizeof(buf));
44//发送
45send(netfd, buf, strlen(buf), 0);
46}else if(cfd == netfd){
47// 读取socket数据
48char buf[60] = {0};
49int res_recv = recv(netfd, buf, sizeof(buf), 0);
50if(0 == res_recv){
51printf("对方断开 \n");
52goto end;
53}
54// 打印数据
55write(STDOUT_FILENO, buf, sizeof(buf));
56}
57}
58}
59end:
60close(netfd);
61close(sofd);
62return 0;
63}
在epoll_wait 的两种就绪触发方式中: 一种是
默认
的水平触发方式(Level-triggered),另一 种是边缘触发模式(Edge-triggered) 。
水平触发是
epoll
的默认工作模式。在这种模式下,只要被监视的文件描述符上有待处理的事件,epoll_wait
就会通知应用程序。以读事件为例子, 这意味着:
如果某个文件描述符变得可读,
epoll_wait
会通知你这个文件描述符可读,即使你没有一次读完取缓冲区当中存在数据 ,下一次调用epoll_wait
时还会再次通知你就绪。
EgCode:
xxxxxxxxxx
141// ...
2}else if(cfd == netfd){
3// 读取socket数据
4char buf[2] = {0}; // 数组长度设置为2
5int res_recv = recv(netfd, buf, sizeof(buf), 0);
6if(0 == res_recv){
7printf("对方断开 \n");
8goto end;
9}
10// 打印数据
11write(STDOUT_FILENO, buf, sizeof(buf));
12printf("-- \n");
13}
14// ...
边缘触发它只在文件描述符状态变化时通知应用程序,即从不可用变为可用。以读事件为例子,在边缘触发模式下:
如果缓冲区中存在数据,但是数据一直没有增多,那么epoll_wait就不会就绪,只有缓冲区的数据增多的时候,才能使epoll_wait就绪。
边缘触发的设置, 是在
epoll_ctl
的时候, 给struct epoll_event *event
参数的events
设置EPOLLET
EgCode:
xxxxxxxxxx
211//...
2
3int epollfd = epoll_create(1);
4struct epoll_event event;
5event.events = EPOLLIN|EPOLLET; // 设置边缘触发
6event.data.fd = netfd;
7epoll_ctl(epollfd, EPOLL_CTL_ADD, netfd, &event);
8
9// ...
10
11}else if(cfd == netfd){
12char buf[2] = {0};// 数组长度为2
13int res_recv = recv(netfd, buf, sizeof(buf), 0);
14if(0 == res_recv){
15printf("对方断开 \n");
16goto end;
17}
18write(STDOUT_FILENO, buf, sizeof(buf));
19printf("-- \n");
20}
21// ...
在上述案例中, 假设对方发送10个字符, 上面的recv操作只能读取其中2个字符, (即使在剩余8个字符的情况下); 当下一次对方又发送10个字符, recv是在缓冲区18个字符的基础上, 再读取两个字符。
如果我们想一次读完对方发送过来的10个字符: 可以配合while循环读取:
EgCode:
xxxxxxxxxx
161// ...
2}else if(cfd == netfd){
3while(1){
4// 读取socket数据
5char buf[2] = {0};
6int res_recv = recv(netfd, buf, sizeof(buf), 0);
7if(0 == res_recv){
8printf("对方断开 \n");
9goto end;
10}
11// 打印数据
12write(STDOUT_FILENO, buf, sizeof(buf));
13}
14printf("-- \n");
15}
16// ....
通过改进之后, 我们确实可以再一次就绪中把所有字符读取出来; 但是新的问题出现了, 我们将没有办法跳出次while循环, 原因是revc操作是一个阻塞方法, 当我们通过while循环读取10个字符以后, 下一次循环执行到recv操作, 即没有字符可以读取, recv会阻塞在这里, 等待用户下一次输入。
在上述问题中, 为了让while循环读取的时候, 在读取完毕跳出循环, 我们可以把 recv从阻塞方法改为非阻塞方法。
xxxxxxxxxx
10123// receive a message from a socket
4ssize_t recv(
5int sockfd,
6void *buf,
7size_t len,
8int flags// 接收行为的标志位:MSG_PEEK(查看数据但不从系统缓冲区中移除)、MSG_DONTWAIT(非阻塞状态)
9);
10// 返回值: 成功时返回实际读取的字节数。如果连接已经关闭返回0(对方close: 四次挥手)。读取失败返回-1
我们可以把flags设置为
MSG_DONTWAIT
, 即非阻塞状态。在这种模式下,如果在调用时没有可用的数据可读取,recv
不会阻塞当前线程,而是立即返回-1。
EgCode:
xxxxxxxxxx
191// ....
2}else if(cfd == netfd){
3while(1){
4// 读取socket数据
5char buf[2] = {0};
6int res_recv = recv(netfd, buf, sizeof(buf), MSG_DONTWAIT);
7if(res_recv == -1){
8break;
9}
10if(0 == res_recv){
11printf("对方断开 \n");
12goto end;
13}
14// 打印数据
15write(STDOUT_FILENO, buf, sizeof(buf));
16}
17printf("-- \n");
18}
19// ....
边缘触发一般配合while循环和非阻塞的recv使用。
使用函数 setsocketopt可以调整套接字的属性(man 7 socket)(man 2 setsocketopt)
xxxxxxxxxx
5123// get and set options on sockets
4int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
5int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
optname参数:
SO_RCVBUF和SO_SNDBUF, 用来获取和调整接收/发送缓冲区的大小。(注意到 setsockopt之后再getsockopt 的结果会和之前传入的参数不一致)
xxxxxxxxxx
101int bufsize;
2socklen_t buflen = sizeof(int);
3ret = getsockopt(sfd,SOL_SOCKET,SO_RCVBUF,&bufsize,&buflen);
4printf("bufsize=%d\n",bufsize);
5
6bufsize = 8192;
7ret = setsockopt(sfd,SOL_SOCKET,SO_RCVBUF,&bufsize,sizeof(int));
8ret = getsockopt(sfd,SOL_SOCKET,SO_RCVBUF,&bufsize,&buflen);
9printf("bufsize2=%d\n",bufsize);
10//...
SO_RCVLOWAT和SO_SNDLOWAT:这个参数说明一个缓冲区的下限, 用以设置缓冲区的灵敏度。( 以SO_RCVLOWAT为例: 如果你设置了一个较高的值,那么读操作将等待直到缓冲区中积累了足够多的数据才返回,这可能有助于减少处理数据的次数,提高效率。)
xxxxxxxxxx
161//...
2else if(events[i].data.fd == sfd)
3{
4newFd = accept(sfd,NULL,NULL);
5printf("newFd=%d\n",newFd);
6
7int buflowat= 10;
8//设置接收缓冲区下限
9ret = setsockopt(newFd,SOL_SOCKET,SO_RCVLOWAT,&buflowat,sizeof(int));
10flag = 1;//表示有客户端登录
11event.data.fd = newFd;
12event.events = EPOLLIN;
13ret = epoll_ctl(epfd,EPOLL_CTL_ADD,newFd,&event); ERROR_CHECK(ret,-1,"epoll_ctl");
14}
15//...
16//这样修改了之后,发送方的数据量如果比较少,将不会触发epoll_wait的读就绪