Linux网络编程

2020-06-03
14 min read

本文为阅读《Linux高性能服务器编程》《Linux多线程服务端编程 》 的读书笔记

[TOC]

网络基础

网络测试工具

  • arp
  • 抓包:tcpdump
  • DNS:host -t A www.bing.com
  • telnet:tcp 连接测试 telnet 127.0.0.1 12345
  • lsof,列出当前系统打开的文件描述符
  • strace,测试服务器性能工具,用于追踪程序执行过程中执行的系统调用和接收到的信号,并显示部分信息
  • netstat,网络统计工具,Recv-Q 和 Send-Q 分别表示内核缓冲区接收队列和发送队列的长度,系统负载高时可能非 0
  • vmstat,实时输出系统各类资源的使用情况
  • mpstat,实时监测多处理器系统上每个 CPU 的使用情况
  • ifstat,简单的网络流量监控工具
  • nc,用于快速构建网络连接,可以作为服务器也可以作为客户端

网络分层

  • 链路层

    • ARP/RARP 协议
    • 以太网帧(MTU,Max Transmit Unit):
      • 6字节目的物理地址|6字节源物理地址|2字节类型|46~1500字节数据|4字节CRC
      • 去除 IP 报文最少 20 字节的头,以太网帧最大有效数据载荷为 1480字节
      • IPv6 的以太网帧和 IPv4 是不同的
  • 网络层

    • IP 协议(V4)

      4 位版本号|4 位头部长度(单位:4 字节)|8 位服务类型(TOS)|16 位总长度(字节)
                  16 位标识符              |    3 位标志    |      13 位片偏移 
          8 位TTL      |    8 位协议       |         16 位头部校验和
                                     32 位源IP地址
                                     32 位目的IP地址
                                     选项,最多 40 字节
      
      • TOS,部分字段已废弃但可以设置 4 种服务类型:最小延时(SSH/telnet)、最大吞吐量(ftp)、最高可靠性、最小费用
      • 16 位标识符用于标识数据报,后面的 16 位用于设置分片,同一个报文的所有分片有相同的16位标识;
      • 3 位标志的第二位设置为1表示禁止分片,此时报文超过 MTU 则被抛弃并返回 ICMP 差错报文;第三位设置为1表示当前报文为分片报文;三位标识符的第一位保留;13位片偏移乘以 8为真实的偏移(相对于数据报起始位置)
      • 8 位协议用于指明 IP 报文中数据的上层协议,比如:TCP(6)、ICMP(1)、UDP(17) 等等
      • 最多40字节的选项,记录路由、记录路由时间戳、指定路由 IP 地址列表(比如 Traceroute 程序)
    • IPv6 协议

           4位版本号      |     8位通信类型(与上面TOS类似)     |         20位流标签
      16位净荷长度(扩展头与应用数据长度之和)|8位下一个包头(固定头部后面数据类型,如TCP)|8位跳数限制
                                     128位源IP地址
                                     128位目的IP地址
                                 扩展头部或者上层协议数据
      
    • ICMP 协议,IP 协议的补充,主要用于检测网络连接,ICMP 被封装在 IP 报文中

      • ICMP 报文分两大类:差错报文(比如目标不可达)、查询报文(比如 PING)
      • ICMP 重定向, 当路由器检测到一台机器使用非优化路由时会发出重定向报文,促使服务刷新路由表
      8bit类型 | 8bit代码 | 16bit校验和
         ...    报文内容   ...
      
  • 传输层

    • TCP/UDP/SCTP

    • TCP 头部 信息

                  16位源端口               |            16位目的端口
                                       32位序号
                                       32位确认号
      4位头部长度(单位4字节) |   6位保留   |  URG|ACK|PSH|RST|SYN|FIN|   16位窗口大小
      16位校验和(CRC 同时校验头部与载荷)   |              16位紧急指针
         选项,最多40字节(例如协商最大报文长度、窗口扩大因子协商、选择性确认SACK 等等)
      
      • URG,紧急指针是否有效,可以使用 URG 实现带外数据;PUH,提示接收端尽快从缓冲区取数据;RST,请求重新建立连接,即复位,比如访问不存在的端口,目的方会发 RST
      • 16 位窗口大小:告知发送方本地还有多少字节的缓存可用,即 receiver window,RWND
      • 16 位紧急指针,紧急数据的偏移
  • 应用层

    • PING/TELNET/OSPF/DNS

网络封装

应用层 应用程序数据
传输层 TCP/UDP头部 应用程序数据
网络层(报文) IP头部 TCP/UDP头部 应用程序数据
链路层(帧) 以太网头 IP头部 TCP/UDP头部 应用程序数据 以太网尾部

Socket 基础

常见概念

  • 主机序与网络序
  • 命名 socket,将 socket 绑定一个指定的地址称为 socket 命名,一般是调用 bind 函数
  • 监听 socket,即调用 listen
  • 接受连接,accept
  • close & shutdown,close 只是将 socket 的引用减一,只有引用为 0 时系统才会真正关闭 socket。可以使用 shutdown 直接关闭 socket
  • MSG_OOB,使用 send/recv 发送接收数据时表明发送或接受紧急数据
    • send( sockfd, oob_data, strlen( oob_data ), MSG_OOB ); 发送紧急数据
    • send( sockfd, normal_data, strlen( normal_data ), 0 ); 发送普通数据
    • 可以使用 sockatmark 判断 socket 后续读取的数据是否位紧急数据,是则返回 1 否则返回 0
  • 对监听 socket 设置的 socket 选项 accept 返回的 socket 将自动继承这些选项

常用 Socket 选项

  • SO_REUSEADDR,强制重用处于 TIME_WAIT 的 socket 地址
  • SO_REUSEPORT,这个 socket 选项是 linux 3.9 内核新增的wiki,允许多进程同时监听相同的 socket,内核负责 accept 的负载均衡。因为是新的选项,所以只有比较新的 Linux 发行版本才支持,比如 Centos7/Ubuntu14 以后的版本。网上有很多测试文章,表名这个选项在超大规模请求下对系统性能的提升,而且有开源实现(如新浪的 Fastsocket)利用这个选项进一步提升了某些场景下的性能。因为这个特性是内核实现的,所以内核可以通过一定的隔离策略减少 cache bouncing 以进一步提高系统性能
  • TCP_NODELAY,禁用 TCP 中的 Nagle 算法。 Nagle 算法的初衷是为了避免发送载荷很小的 IP 报文,将大量小报文合并为一个“大载荷”的报文后统一发出。随着技术的发展,这个算法在当前大带宽的网络环境下意义不大,故常被禁用
  • SO_RCVBUF/SO_SNDBUF,设置 TCP 接收与发送最小值,系统有一定的限制,最小值不能小于多少
  • SO_RECVLOWAT/SO_SNDLOWAT, TCP 连接接收缓冲区和发送缓冲区低水位标记(默认均为 1 字节)。I/O 复用系统一般使用这两个参数来判断 socket 是否可以读写

系统 IO 函数

pipe

pipe 用于创建一个管道,以实现进程间通信

#include<unistd.h>
int pipe(int fd[2]);

dup/dup2

#include<unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one, file_descriptor_two);

dup 用于创建一个新的文件描述符,新文件描述符和原有文件描述符指向相同的文件、管道或者网络连接,并且 dup 返回的文件描述符总是取系统当前可用的最小整数值。dup 和 dup2 的区别在于后者返回的文件描述符不会小于 file_descriptor_two

举个例子,如果我们创建了一个网络连接 sock1,其文件描述符值为 fd1。随后关闭标准输出文件描述符并使用 dup 处理 fd1 ,则 dup 会返回当前系统最小可用的文件描述符,即刚关闭的标准输出(假设标准输入没有被关闭),且这个文件描述符绑定 sock1。此后我们写入标注输出的数据都将作为输入流写进 sock1

// #define	STDIN_FILENO	0	/* Standard input.  */
// #define	STDOUT_FILENO	1	/* Standard output.  */
// #define	STDERR_FILENO	2	/* Standard error output.  */

close( STDOUT_FILENO ); // 关闭标准输出
dup( connfd );          // 将标准输出绑定到 connfd 对应的 socket
printf( "abcd\n" );     // 输出到标准输出的数据将传给 socket
close( connfd );

readv/writev

分散度与集中写

sendfile

在内核中直接在两个文件描述符之间传输数据,避免内核缓冲区和用户缓冲区的数据拷贝

mmap/munmap

mmap 函数用于申请一段内存空间,这段内存空间可以进程共享,也可以将文件映射到这块内存中

splice/tee

splice 用于在两个 文件描述符之间移动数据,零拷贝

tee 用于在两个管道文件描述符之间复制数据,零拷贝且不消耗数据

fcntl/ioctl

fcntl 和 ioctl 均是文件描述符设置方法,但 POSIX 规定的首选文件描述符属性修改函数是 fcntl,下面是 fcntl 的一个简单示例

int setnonblocking( int fd ) {
    int old_option = fcntl( fd, F_GETFL );
    int new_option = old_option | O_NONBLOCK;
    fcntl( fd, F_SETFL, new_option );
    return old_option;
}

高性能服务程序结构

IO

IO 模型可以分为同步模型异步模型,同步模型也分为阻塞和非阻塞两种

IO 模型 读写操作和阻塞阶段
阻塞 IO 程序阻塞于读写函数
IO 复用 程序阻塞于 IO 复用系统调用,程序对 IO 的读写操作时非阻塞的
SIGIO 信号 信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段
异步 IO 内核执行读写操作并触发读写完成事件,程序没有阻塞阶段

同步 IO

同步 IO 指的是处理前 IO 事件已经准备好(可读或者可写),否则线程会阻塞,并阻塞在读写函数,如 read/write;也可能阻塞在 IO 复用函数上,如 select/poll/epoll_wait

非阻塞 IO

调用 socket 相关函数时可以设置 SOCK_NONBLOCK 来表明当前 socket 非阻塞。针对非阻塞 IO 执行的系统调用总是立即返回,如果事件没有发生,系统调用会返回 -1 并设置 errno 来区别错误和事件未发生

  • accept/send/recv,事件未发生时 errno 常被设置为 EAGAIN 或者 EWOULDBLOCK (期望阻塞)
  • connect,事件未发生时 errno 被设置未 EINPROGRESS (处理中)

异步 IO

IO 操作全部由操作系统内核完成,IO 操作完成后系统会调用相应的回调函数

同步模型通知应用程序事件就绪,异步模型通知程序 IO 已经完成。启动异步写时,应用程序会通知内核数据源;启动异步读时,应用程序会通知内核将数据写到哪里

Reactor/Proactor

  • Reactor,主线程只负责监听文件描述符是否发生相关事件,相关事件发生后唤醒工作线程,后续的 IO 与事务处理全部由工作线程完成
  • Proactor,IO 操作全部交由主线程和内核处理,工作线程只处理业务逻辑

并发模型

对于 CPU 密集型任务,并发模型的优劣对系统性能的影响并没有 IO 密集型任务明显。对于 IO 密集型任务,异步模型是性能最高的,但异步模型在编码上难以扩展与维护,所以大部分并发模型都是同步和异步相结合的

半同步/半反应堆模型

半同步/半反应堆模型(half-sync/half-reactive)模型异步处理 IO 事件,同步处理业务流程,是典型的 reactor 模式

graph LR
	A[主线程处理IO<br/> epoll_wait]
	B[请求队列]
	C[工作线程 1]
	D[工作线程 2]
	E[工作线程...]
	
	A -->|accept<br/>插入socket| B 
	B -->|获取socket| C
	B -->|获取socket| D
	B -->|获取socket| E

此类模式有两个缺陷:

  1. 主线程和工作线程共享请求队列,需要使用锁保护共享变量,使用锁会消耗系统资源
  2. 每个线程某一时刻只能处理一个客户请求,如果使用同步 IO ,工作线程还需要处理网络传输,线程资源会浪费在 IO 处理过程中;处理完成后需要再次访问请求队列,这个过程也比较浪费时间
改进(利用分发)

并发编程有两种基本的模型,一种 message passing,另一种 shared memory,为了减少共享变量的存在,一般考虑 message passing 模型

工作线程维护自己的 epoll 事件集,主线程只用于监听连接请求,有新的请求后将 accept 生成的 socket 分配给工作线程(比如使用 pipe 管道 实现),这样即去除了共享变量,也可以让线程一次 epoll_wait 获得多个就绪 IO 事件,从而减少系统调用的次数

与改进前的方法做对比,可以认为是主动和被动的区别。工作线程主动获取数据,必然需要知道一个共享的数据变量;被动获取,则不需要知道额外的信息。被动获取信息最大化的去除了共享的数据变量,减少了竞态,提高了性能。被动获取数据,说明工作线程需要维护额外的数据结构,说明分发是以空间换时间

graph LR
	A[主线程<br/>监听网络连接请求]
	B[工作线程 1<br/> epoll_wait]
	C[工作线程 2<br/> epoll_wait]
	D[工作线程...<br/> epoll_wait]
	
	A -->|分配已连接socket| B
	A -->|分配已连接socket| C
	A -->|分配已连接socket| D
改进(利用 SO_REUSEPORT

Linux 3.9 内核之后为 Socket 新增了一个 SO_REUSEPORT 选项,所有网络线程可以绑定同一个监听 socket,而且内核会实现 accept 在不同线程间的负载均衡,那上图中主线程监听网络请求与分发的过程都可以交给各线程进行处理了,进一步减少了公有变量

改进 (利用无锁队列)

在高速网络连接的场景下,网络处理职能可以从工作线程中剥离,工作线程只处理业务逻辑(proactor)。网络线程将收到的业务数据以分发的形式提供给工作线程,对于网络线程和工作线程而言,是单生产者与单消费者模型

某些场景下使用无锁队列可以进一步提高系统的性能,无锁队列可参考C++原子性实现无锁队列,单生产者与消费者模型甚至可以不使用原子操作(可参考这里),即 wait-free

说到无锁队列,这里额外提一些知识。非 wati-free 的无锁队列(lock-free)大部分基于 CAS,而 CAS 有 ABA 漏洞,设计队列时一定要仔细考虑这个问题,不然代码会出现意想不到的异常现象。一般 ABA 涉及的问题与内存的动态分配相关【参考】,故大部分无锁队列都使用数组实现,以避免内存的动态分配。更高效的无锁队列可以参考这里,请注意伪共享【参考1/参考2】和 cache bouncing 的概念,其中 cache bouncing 优化在多线程中对系统性能的影响是非常显著的。下面是一个例子,可以看出 cache line 对性能影响的显著性

/***************************************************
	编译指令:clang++ test.cpp
		32768000128000000, ms: 594.508
		32768000128000000, ms: 3272.57
		ms_2/ms1: 5.50467
	编译指令:clang++ -O2 test.cpp,输出:
		32768000128000000, ms: 63.7775
		32768000128000000, ms: 1998.34
		ms_2/ms1: 31.3331
***************************************************/
#include <iostream>
#include <chrono>
#include <vector>
#include <algorithm>

using namespace std;

const int PAGE_SIZE = 256; // 一般 INTEL CPU 的 cache line 为 64 字节
const int PAGE_NUMS = 1000000;

int main() {
    vector<int> data(PAGE_SIZE * PAGE_NUMS, 0);
    int tmp = 0;
    transform( data.begin(), 
               data.end(), 
               data.begin(), 
               [&tmp](int d) -> int { return ++tmp; });

    auto t1 = std::chrono::steady_clock::now();
    long long sum_data = 0;
    for (int i = 0; i < PAGE_NUMS; i++) { 
        for (int j = 0; j < PAGE_SIZE; j++) { 
            sum_data += data[i * PAGE_SIZE + j]; // 顺序读取内存中的数据
        }
    }
    auto t2 = std::chrono::steady_clock::now();
    auto ms_1 = chrono::duration<double, std::milli>(t2 - t1).count();
    cout << sum_data << ", ms: " << ms_1 << endl;

    // step cache line
    sum_data = 0;
    for (int i = 0; i < PAGE_SIZE; i++) { 
        for (int j = 0; j < PAGE_NUMS; j++) { 
            sum_data += data[j * PAGE_SIZE + i]; // 强制 CPU 刷新 cache line
        }
    }
    auto t3 = std::chrono::steady_clock::now();
    auto ms_2 = chrono::duration<double, std::milli>(t3 - t2).count();
    cout << sum_data << ", ms: " << ms_2 << endl;

    cout << "ms_2/ms1: " << 1.0*ms_2/ms_1 << endl;
}

领导者/追随者模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件(可以借用 epoll_wait 的线程安全特性将所有线程阻塞在同一个 epoll 对象上)。任意时间点,程序仅有一个领导者线程,它负责监听 IO 事件并在处理业务前选举另一个线程作为领导者来监听事件集,随后领导者转换身份变为处理者

当前模式每个线程都有三种状态:

  1. Leader,负责监听事件集
  2. Processer,处理事件
  3. Follower,处于就绪状态的线程

三种状态的转换图如下:

graph RL
	A[Processer]
	B[Leader]
	C[Follower]
	
	A -->|事件处理完成<br/>且当前没有 Leader| B
	A -->|事件处理完成<br/>且当前有 Leader| C
	B -->|处理事件| A
	C -->|被选举为<br/>Leader| B
	C -->|Leader 交代任务| A 	

业务处理

有限状态机

一般的业务场景,工作线程需要处理各类不同的任务,这个过程一般通过消息中业务标识来实现。大部分状态之间的孤立的,但有些任务之间的状态会相互转移,比如 HTTP 协议的解析,代码示例如下:

while( ( linestatus = parse_line( buffer, checked_index, read_index ) ) == LINE_OK ) {
	...
    switch ( checkstate ) {
        case CHECK_STATE_REQUESTLINE: {
                retcode = parse_requestline( szTemp, checkstate ); // 解析请求行
                if ( retcode == BAD_REQUEST ) return BAD_REQUEST;
                break;
            }
        case CHECK_STATE_HEADER: {
                retcode = parse_headers( szTemp ); // 解析首部行
                if ( retcode == BAD_REQUEST ) return BAD_REQUEST;
                else if ( retcode == GET_REQUEST ) return GET_REQUEST;
                break;
            }
        default: return INTERNAL_ERROR;
    }
}
if( linestatus == LINE_OPEN ) return NO_REQUEST;
else return BAD_REQUEST;

HTTP 解析过程中状态之间需要转移是因为 http 报文可能一次读不完需要分多次读取,而且报文结构明显,不同部分需要用不同的处理策略

其他影响系统性能的部分

  • 池化
  • 尽量减少数据的无用复制,进程间数据拷贝可以考虑共享内存
  • 上下文切换,不要设置过多的线程数或者进程数,一般将线程数可以使用下面公式确定
    • 最佳线程数目 = ( ( 线程等待时间 + 线程 CPU 时间 )/线程CPU时间 ) * CPU数目
  • 锁,尽量减少锁的使用,必须使用锁时尽量减小锁的粒度

IO 复用

Linux 下常用的 IO 复用技术有 select/poll 和 epoll,这些工具赋予了应用程序同时监控多个描述符的能力

本文参考的书籍中对 IO 复用有大量的讲解,这里只记录一些比较少见的内容

文件描述符就绪条件

什么情况下 IO 复用函数会返回?下面以 socket 可读/可写为例进行说明

首先说明 socket 可读的条件:

  1. socket 内核接收到的字节数大于或者等于其低水位标记 SO_RCVLOWAT ,默认是 1 字节;读操作返回大于 0 的读取字节数
  2. socket 通信的对方关闭连接,此时 socket 读操作返回 0
  3. 监听 socket 上有新的连接请求
  4. socket 上有未处理的错误,可以使用 getsockopt 实现错误的读取与清除

其次描述 socket 可写的条件:

  1. socket 内核发缓冲区可用字节数大于或等于低水位标记 SO_SNDLOWAT,默认 1 字节;写操作返回写入的字节数(大于 0)
  2. socket 写操作被关闭,此时写会触发 SIGPIPE 信号
  3. sicket 使用非阻塞 connect 连接成功或者失败后
  4. socket 上有未处理的错误

select/poll(略)

epoll

注意下面的 EPOLLONESHOT

epoll 适合连接数众多,但活动相对较少的场景,如果所有文件描述符都是频繁活动的,那么 epoll 的性能相对于 select/poll 而言并不明显。常用的 epoll 信息如下,其他详细信息请参考其他资料

#include <system/epoll.h>
int epoll_create(int size); // 新的 linux 下,size 参数已经不起作用了
// EPOLL_CTL_ADD  EPOLL_CTL_MOD  EPOLL_CTL_DEL
// epoll_ctl( epollfd, EPOLL_CTL_ADD, fd, &event );
int epoll_ctl(int epfd, int op, int fd, struct epoll_event * event);// 成功返回 0 失败返回 -1
int epoll_wait(int epfd, stuct epoll_event* events, int maxevents, int timeout);

struct epoll_event{
    __uint32_t events; // epoll 事件,event.events = EPOLLIN,event.events |= EPOLLET;
    epoll_data_t data;// 用户数据
}

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

ET/LT

LT 触发意味着只要事件满足要求,就会通知应用程序,比如缓冲区有数据可读,则必然触发 LT

ET 触发意味着只有描述符状态发生变化,才会通知应用程序,比如缓冲区可用数据的从 0 到 1 。如果仅仅是缓冲区的有效数据变少或者变多(并未发生状态变化,如空/非空的转化)则系统不会通知应用程序

应用程序在使用 ET/LT 时的模式也是不同的,前者需要一次读完缓冲区的数据,否则有新数据到来时不会触发通知

EPOLLONESHOT

使用 ET 模式时可能会出现多个线程同时处理一个 socket 任务的情况,比如线程 A 首先被触发并读取完 socket1 内核缓冲上的内容;随后 socket1 接收到新的数据,此时线程 B 被触发并读取 socket1 的数据

为避免上面的情况,可以为 socket 设置 EPOLLONESHOT 属性,这样在重置 EPOLLONESHOT 前, epoll 只会触发对应 socket 的一次可读可写或者异常事件

信号(略)

尽量将信号和 IO 事件的处理都放到主循环中

定时器

Linux 提供了三种定时方法:

  1. socket 选项 SO_RCVTIMEOSO_SNDTIMEO
  2. SIGALRM 信号,定时器链表/定时器时间轮/定时器时间堆
    1. 一般将定时器设置一个固定的值,周期性的发出 SIGALRM 信号。如此我们就需要保存不同的定时任务信息,这些信息可以使用不同的数据结构进行保存,比如链表、时间轮、堆等等方式
  3. IO 复用系统调用的超时参数

Libevent (略)

进程间工具

fork/exec/pipe

信号量

信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作:

  1. P(passeren,传递,即尝试进入临界区) ,如果信号量值大于 0 则将其减一,如果信号量值为 0 则挂起进程
  2. V(vrijgeven,释放,类似于离开临界区),如果有其他进程因等待而挂起则唤醒对应进程,否则将信号量值加一

这两个单词是荷兰语

共享内存/消息队列

共享内存是效率最高的进程间通信方式,但需要自行管理竞态问题