网络编程

1, 前言

TCP/IP协议族标准只规定了网络模型中各个层次设计和规范,具体实现则需要由各个操作系统厂商完成。最出名的网络库由BSD 4.2版本最先推出,所以称作伯克利套接字/Socket,这些API被各大Unix操作系统厂商学习, 随后被移植到各大操作系统中,并成为了网络编程的事实标准。

Socket 即套接字是指网络中 一种用来建立连接、网络通信的设备,用户创建了Socket之后,可以通过其发起或者接受TCP连接、可以向TCP的发送和接收缓冲区当中读写TCP数据段,或者发送UDP文本。

2, 地址处理

2.1 大端地址和小端地址

大端法和小端法

大端法和小端法是两种定义了多字节数据在计算机内存中的存储顺序

大端法/Big-Endian: 是指高位字节存储在内存的低地址端,而低位字节存储在内存的高地址端。

小端法/Little-Endian: 和大端法相反,低位字节存储在内存的低地址端,高位字节存储在内存的高地址端。

而TCP/IP协议规定,当数据在网络中传输的时候,一律使用网络字节序即大端法。而"一般"主机比较多的用小端法存储数据(以x86,x64架构为例, 即大多数个人电脑和服务器,包括 Intel 和 AMD 的处理器)。

Eg: 网络字节序

Eg: 以Linux为例我们可以同lscpu命令, 查看主机存储顺序。

大小端转化

根据上面所述: 网络字节序使用大端法, 主机字节序使用小端法, 我们需要对大小端进行转化。

在Linux中定义了相关转化的函数。( man htonl )

Eg: 代码示例

2.2 点分十进制转化: 要用结构体类型

在Socket编程中POSIX 套接字接口设计上提供了多个结构体, 以供我们适用在不同的情况。

比如sockaddr 结构体, 这是一种通用的地址结构,它可以通用的描述IPv4和IPv6的结构,而且基本上所有涉及到地址的接口都使用了该类型作为参数。(比如: 上面addrinfo结构体中, sockaddr *ai_addr 参数, 就使用sockaddr类型 )

但是由于它定义的过于通用, 它直接把一个具体的IP地址和端口信息混在一起, 使用起来过于麻烦; 我们需要更具体的IPV4和IPV6类型, 所以POSIX标准又更进一步的定义了sockaddr_insockaddr_in6分别用于描述IPV4和IPV6类型。 并且, 在需要通用地址参数的函数调用中(例如,bind()connect()accept()等, 他们需要sockaddr类型的参数),我们可以直接将 sockaddr_insockaddr_in6 结构体的指针转换为 sockaddr 类型使用,  这种转换是安全的。

在日常生活中我们更习惯与把IP地址书写成点分十进制, eg: 192.168.10.100...; 当我们需要通过Socket进行网络交互的时候, 我们怎么把它转化为合适的类型?

在POSIX 套接字接口设计上提供了结构体in_addrin6_addr, 分别用来存储IPv4和IPv6类型的IP地址( man inet_aton)。以IPv4为例。(man 7 ip)

这也就意味着, 我们需要一套把点分十进制的IP地址, 转为无符号int的手段。POSIX 套接字接口同时也设计了一套函数来实现该问题。(man inet_aton)

EgCode1:

EgCode2:

EgCode3:

2.3 DNS: 记住理论

我们已知域名和IP地址的关系, 显然我们需要一种机制去建立域名和IP地址的映射关系, 一种方法是修改本机的hosts文件/etc/hosts,但是更加通用的方案是利用DNS协议,去访问一个DNS服务器,服务器当中存储了域名和IP 地址的映射关系。与这个操作相关的函数是gethostbyname和getaddrinfo。(man getaddrinfo) (gethostbyname我们认为属于过时状态, 有兴趣同学可以了解: man 3 gethostbyname )

EgCode:

3, TCP

3.1 TCP通信流程

基于TCP通信的Socket基本流程:

3.1.1 Socket

初始化Socket: 调用Socket函数创建一个Socket通信端点 (参考: man 2 socket)

  • 实际上, 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文件指定)

3.2.2 Bind

绑定地址: 使用bind函数socket端点绑定端口和IP (函数参考: man 2 bind)

  • const struct sockaddr *addr参数: 该参数用于提供给socket端点IP和端口信息, 但是sockaddr是一个通用的地址结构,实际使用的时候还是要使用sockaddr_in (IPv4)sockaddr_in6 (IPv6)

  • 在选择端口号设置时, 建议应当避开知名端口号的范围(<1024)。

  • 使用bind 函数时要注意其地址是大端法描述的,可能需要执行强制类型转换。

  • IP设置:

ps1: 一般我们都是给服务端bind, 那么客户端也可以bind吗?

  • 正常来讲客户端不需要bind; 客户端不bind操作系统都会分配一个临时的随机端口, 这已经足够使用了。

  • 当然如果有特殊需求, 也可以对客户端进行bind, 用以指明发送和接收数据的IP和端口。

ps2: 服务端可不可以不bind?

  • 如果服务端不进行bind操作, 一般操作系统都会分配一个临时的随机端口以供使用, 但是从逻辑上完全没有任何意义, 不允许这样操作。

3.1.3 Listen

设置监听: 使用listen函数对设置好端口和IP的服务端socket端点监听外部连接请求 (函数参考: man 2 listen)

  • 一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区(回收空间),转而在内核区维护两个队列结构: 半连接队列和全连接队列。

  • backlog参数实际上是用来设置Socket的ACCEPT队列/全连接队列的最大长度(在有的操作系统上backlog指的是半连接队列全连接队列的长度之和) , 给一个一般性的正数即可。

  • 需要注意的是, 如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复,以方便客户端自动重传)

ps: 可以使用 netstat -an 命令可以查看主机上某个端口的监听情况 (eg: netstat -an|grep 12345)

3.1.4 Connect

建立连接: 使用connect函数使客户端服务器发送建立连接请求,初始化一个连接 (man 2 connect)

  • 客户端在调用connect可以不使用bind来指定本地的端口信息,这客户端就会随机选择一个临时端口号来作为源端口。

  • 调用connect预期是完成TCP建立连接的三次握手。 如果服务端未开启对应的端口号或者未监听,则只能收到一个RST回复,并且报错返回的内容 是"Connection refused"。

3.1.5 Accept

获取连接: 使用accept函数服务端socket端点的全连接队列中取出一个连接 (man 2 accept)

  • 需要特别注意的是, addrlen参数是一个传入传出参数,所以使用的时候(非NULL)需要主调函数提前分配好内存空间:sizeof(addr)

  • accept 函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接(三次握手)。如果全连接队列为空(没有新的客户端成功三次握手),那么accept会陷入阻塞。 一旦全连接队列中到来新的连接,此时accept操作就会就绪 (注意: 这种就绪是读就绪)。

  • 当accept执行完了之后,内核会创建一个新的套接字文件对象,该文件对象关联的文件描述符是accept的返回值,文件对象当中最重要的结构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。

ps: 注意区分两个套接字对象: 通过把旧的管理连接队列的套接字称作监听套接字,而新的用于发送和接收TCP段的套接字称作已连接套接字。通常来说,监听套接字会一直存在,负责建立各个不同的TCP连接(只要源IP、源端口、目的IP、目的端口四元组任意一个 字段有区别,就是一个新的TCP连接),而某一条单独的TCP连接则是由其对应的已连接套接字进行数据通信的。

3.1.6 Send和Recv

发送和获取数据: 客户端OR服务端使用 Send和 Recv用于发送和接收TCP数据。 (man 2 send) (man 2 recv)

  • 需要注意的是, Send和 Recv函数只是将数据在用户态空间和内核态的缓冲区之间进行传输。

  • send时将数据拷贝到内核态并不意味着会马上传输,而是由操作系统决定根据合适的时机, 再由内核协议栈按照协议的规范进行分节发送。(通常缓冲区如果数据过多会分节成 MSS的大小,然后根据窗口条件传输到网络层之中)

  • 对于发送和接收数据, 使用Read和Write函数可以实现同样的效果(本质是相同的),相当于flags 参数为0。

注意:

3.1.7 close

关闭连接: 客户端或者服务器使用close函数关闭服务器. (man 2 close)

  • 客户端或者服务器使用close函数关闭连接的时候, 可能还有数据留在发送缓冲区中未被发送, close操作会试图发送这些数据。

  • close函数给连接的对端发送FIN包用于断开连接的四次挥手, 等待连接的另一端也发送FIN包,并且本端回应ACK确认关闭。

  • 释放端口等资源。

3.2 TCP通信代码示例

3.2.1 客户端

EgCode

3.2.2 服务端

EgCode

3.3 结合Select通信

3.3.1 Select简单回顾

select函数

3.3.2 Select通信示例

EgCode: 客户端

EgCode: 服务端

端口占用:拓展

当我们关闭正在运行的服务端之后, 在短时间内尝试重启服务端有可能失败, 这个错误发生的原因是当重启服务端时, 服务端在尝试bind一个网络地址(IP 地址和端口号)到套接字上时,端口号已经被另一个套接字占用。如图:

而在实际工作当中, TIME_WAIT状态的存在虽然有可能会提高连接的可靠性,但是一个服务端当中假如存在大量的TIME_WAIT状态,那么服务端的工作能力会极大地受到限制,而取消TIME_WAIT状态其实对可靠性的影响比较小,所以用户可以选择使用setsockopt 函数修改监听套接字的属性,使其可以在TIME_WAIT状态下依然可以bind重复的地址, 重新接收用户端握手请求。 (man 2 setsockopt)

ps: setsockopt 函数需要在bind函数之前执行, socket函数之后

EgCode:

3.3.4 Select断开重连

客户端和服务器之间的连接可能由于各种原因断开。为了维持一个持久的会话和提供不间断的服务体验,当客户端软件尝试重新建立连接的时候。服务器应该启动重连机制,重新连接服务器。我们可以通过Select和Socket模拟这个过程。

EgCode:

3.4 其它: 了解

3.4.1 tcpdump操作

tcpdump: connect时可以使用tcpdump命令可以查看包的状态

解决网络问题的一般流程

3.4.2 DDOS

利用半连接队列的设计思路,网络攻击者想到了一种恶意攻击的方法。他们伪造一些SYN请 求但是并不打算建立连接,这些请求的源地址随机构建的,或者是感染其他计算机来发起请求,服务端内核就会维持一个很大的队列来管理这些半连接。当半连接足够多的时候,就会导致新来的正常连接请求得不到响应, 也就是所谓的DDOS攻击。

当然也可以通过减SYN+ACK重传次数、增加半连接队列长度、 启用syncookie 等手段防御DDOS,不过在高强度攻击面前,调整tcp_syn_retries 和 tcp_max_syn_backlog并不能解决根本问题。更有效的防御手段是激活tcp_syncookies — —在连接真正创建起来之前,它并不会立刻给请求分配数据区存储连接状态,而是通过构建一个带签名的序号来屏蔽伪造请求。

4, UDP

4.1 UDP通信流程

基于UDP通信的Socket基本流程:

4.1.1 Socket

  • 函数定义和使用同TCP(3.1.1)。

  • Socket函数的type: SOCK_STREAM (TCP)、SOCK_DGRAM (UDP)。

4.1.2 Bind

  • 函数定义和使用同TCP(3.1.2)。

4.1.3 Sendto和Recvfrom

发送和获取数据: 客户端OR服务端使用 Sendto和 Recvfrom用于发送和接收UDP数据。 (man 2 sendto) (man 2 recvfrom)

  • 和基于TCPsend和recv函数不同的是, 基于UDPsendto和recvfrom函数携带了地址信息, 用于确定目的地址和获取接收的信息的来源地址。

  • 这也就意味着, 在使用UDP进行无连接的通信时, 因为没有建立连接的过程,所以必须总是由客户端先调用sendto发送消息给服务端,这样服务端才能知道对端的地址信息,从进入后续的通信。

  • 在使用UDP进行无连接的通信时, 因为是无连接的, 所以客户端或服务端关闭, 对方无法直接感知。

  • 需要注意的是类型和参数: socklen_t *addrlen (非int) (socklen_t 本身是unsigned int)

4.1.4 Close

  • 函数定义和使用同TCP(3.1.7)。

4.2 UDP通信代码示例

4.2.1 客户端

EgCode:

4.2.2 服务端

EgCode:

4.2. 3 练习

模拟群聊实现

超时踢出群聊: 10s

5, Epoll

IO多路复用: select epoll

epoll: 监听集合和就绪集合分离: 不需要重置监听集合, 监听集合不需要再用户态和内核态来回拷贝

epoll: 没有1024的文件描述符监听的大小限制

epoll: 不像select需要反复调用cpu一直轮询是否就绪, 只是那个文件状态变化被动通知epoll, epoll再判断

效率差异: epoll效率更高, select效率略低

5.1 Select的缺陷

我们在前面实现I/O多路复用的时候, 选择通过select函数监听文件描述符的方式,以便知道它们在何时变为可读/都就绪或者可写/写就绪状态。这使得程序能够在单个线程或进程中同时管理多个I/O操作,而无需为每个I/O操作分别使用阻塞调用或者为每个操作分配独立的线程或进程。

但是, 在面临大量文件描述符或要求更高性能的场合时,我们可能需要考虑更好的替代方案。

这是因为select在使用的时候, 有一些明显的缺点:

  • select的最大限制是它支持的文件描述符数量有限(fd_set大小1024), 对于大型服务器来说可能不够用。

  • 每次调用select 时,都需要把整个文件描述符集合用户空间/用户态复制到内核空间/内核态,当在内核态检查完就绪, 又需要把就绪描述符集合内核空间/内核态复制到用户空间/用户态。 随着监听集合大小增加,这个操作变得越来越低效。

  • select函数使用的时候,每次调用完select都需要重新设置文件描述符集合,这是因为在select中文件描述符集合的输入输出未分离,返回的fd_set会将未就绪的文件描述符清空。

  • ....

5.2 Epoll特点

为了解决select函数的一些缺点,Linux内核提供了一种具有更好的扩展性和性能的I/O多路复用技术,即epoll。尤其是在处理大量并发网络连接时,epoll是专门为高性能网络服务器设计的,可以有效地管理成千上万的并发连接。(需要注意的是epoll属于Linux内核提供I/O多路复用技术, 在Windows或者MAC系统上, 我们我们可以使用其对应IOCP或者Kqueue)

epoll具有以下显著的特点:

  • 相比较select而言, epoll没有对文件描述符数量进行限制。(它会随着内存的增大而导致上限变化, 可以参考: /proc/sys/fs/file-max )

  • epoll函数创建的epoll文件对象常驻内核态; 并且在该对象内部实现中把监听集合就绪集合拆开维护。其中监听集合使用红黑树, 在管理大量的文件描述符, 可以做到高效的查找、添加和删除对应的文件描述符; 就绪集合使用线性表。

  • 在内核态epoll只检测那些变为活跃状态的文件描述符就绪与否, 而不是像select一样对需要监听的文件描述符集合进行轮询检测。显著减少CPU的负担,提高应用程序的性能。(基于epoll的“回调机制”, epoll注册的文件描述符,其自身状态发生变换, 内核会触发epoll为其预设的“回调函数,” 通知epoll其处于活跃状态)

  • 当应用程序调用 epoll_wait 时,内核会检查这个就绪列表,然后将就绪列表复制到用户空间提供的缓冲区中,以通知应用程序哪些文件描述符上的事件已经就绪。并且在这个过程中, 原文件监听集合是没有被修改的, 也就意味着它不需要像select一样反复初始化监听集合, 不需要像select一样每次调用完select都重新设置文件描述符集合

  • ...

5.3 Epoll函数

(man 7 epoll)

5.3.1 函数定义

创建epoll对象: (man 2 epoll_create)

调整监听事件集合: (man 2 epoll_ctl )

进入阻塞状态,直到监听的设备就绪或者超时: ( man 2 epoll_wait )

5.3.2 代码示例

客户端:

服务端:

5.4 触发模式

在epoll_wait 的两种就绪触发方式中: 一种是默认的水平触发方式(Level-triggered),另一 种是边缘触发模式(Edge-triggered) 。

5.4.1 水平触发

水平触发是 epoll 的默认工作模式。在这种模式下,只要被监视的文件描述符上有待处理的事件,epoll_wait 就会通知应用程序。以读事件为例子, 这意味着:

  • 如果某个文件描述符变得可读,epoll_wait 会通知你这个文件描述符可读,即使你没有一次读完取缓冲区当中存在数据 ,下一次调用 epoll_wait 时还会再次通知你就绪。

EgCode:

5.4.2 边缘触发

边缘触发它只在文件描述符状态变化时通知应用程序,即从不可用变为可用。以读事件为例子,在边缘触发模式下:

  • 如果缓冲区中存在数据,但是数据一直没有增多,那么epoll_wait就不会就绪,只有缓冲区的数据增多的时候,才能使epoll_wait就绪。

  • 边缘触发的设置, 是在epoll_ctl的时候, 给struct epoll_event *event参数的events设置EPOLLET

EgCode:

在上述案例中, 假设对方发送10个字符, 上面的recv操作只能读取其中2个字符, (即使在剩余8个字符的情况下); 当下一次对方又发送10个字符, recv是在缓冲区18个字符的基础上, 再读取两个字符。

如果我们想一次读完对方发送过来的10个字符: 可以配合while循环读取:

EgCode:

通过改进之后, 我们确实可以再一次就绪中把所有字符读取出来; 但是新的问题出现了, 我们将没有办法跳出次while循环, 原因是revc操作是一个阻塞方法, 当我们通过while循环读取10个字符以后, 下一次循环执行到recv操作, 即没有字符可以读取, recv会阻塞在这里, 等待用户下一次输入。

5.4.3 非阻塞模式的recv

在上述问题中, 为了让while循环读取的时候, 在读取完毕跳出循环, 我们可以把 recv从阻塞方法改为非阻塞方法。

我们可以把flags设置为MSG_DONTWAIT, 即非阻塞状态。在这种模式下,如果在调用时没有可用的数据可读取,recv 不会阻塞当前线程,而是立即返回-1。

EgCode:

边缘触发一般配合while循环和非阻塞的recv使用。

4.6 其他

4.6.1 修改socket的属性: 仅作了解

使用函数 setsocketopt可以调整套接字的属性(man 7 socket)(man 2 setsocketopt)

optname参数:

  • SO_RCVBUF和SO_SNDBUF, 用来获取和调整接收/发送缓冲区的大小。(注意到 setsockopt之后再getsockopt 的结果会和之前传入的参数不一致)

  • SO_RCVLOWAT和SO_SNDLOWAT:这个参数说明一个缓冲区的下限, 用以设置缓冲区的灵敏度。( 以SO_RCVLOWAT为例: 如果你设置了一个较高的值,那么读操作将等待直到缓冲区中积累了足够多的数据才返回,这可能有助于减少处理数据的次数,提高效率。)