目录

高性能服务器编程

本文是《Linux 高性能服务器编程》一书的编程笔记。

1. TCP/IP 协议簇

TCP :双方必须先建立链接(三次握手)才可收发数据,内核维护了连接状态、读写缓冲区、定时器等结构。数据传送是基于字节流,一端不断的写入,另一端不断的接收。但通信结束时,必须断开链接(四次挥手),释放内核相关结构数据

UDP:无连接,每次发送数据都要指明接收端地址,基于数据报传输数据,每次发送的数据包的长度固定,接收时也必须一次性读取整个数据包(否则数据就被截断)

ARP

主机向自己所在的网络,广播一个ARP请求(内含目标机器的IP地址),此网络的机器收到这个请求后,和自身IP对比,只有目标机器才会返回一个ARP应答(包含自己的MAC地址)。

参考资料:

ARP 原理和攻击

$ ifconfig          # 查询本机的 ip
enp2s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.0.102  netmask 255.255.255.0  broadcast 192.168.0.255
        inet6 fe80::6aff:f269:1d00:8917  prefixlen 64  scopeid 0x20<link>
# 监听 这两个IP 地址之间所有 的 帧
$ sudo tcpdump -i enp2s0 -ent \
    '(dst 192.168.0.102 and src 101.200.144.41) or (dst 101.200.144.41 and src 192.168.0.102)'

$ sudo tcpdump -i enp2s0 -ent
$ sudo arp  # 查看 arp 缓存

DNS 详解

$ cat /etc/resolv.conf  # 查看 DNS 服务器IP
$ host -t A www.baidu.com   # 查看 baidu 域名的 A 记录
$ sudo tcpdump -i eth0 -nt -s 500 port domain   # 跟踪 domain 查询包的记录

2. IP 协议详解

IP协议为上层应用提供无连接、无状态、不可靠的服务。

以太网帧



5. Linux 网络编程基础 API

socket 地址 API

字节序 API

#include <netinet/in.h>
// 网络字节序 和 主机字节序 转换
unsigned long htonl(unsigned long hostlong); // host to network long
unsigned short htons(unsigned short int hostshort);
unsigned long ntohl(unsigned long netlong);  // network to host long
unsigned short ntohs(unsigned short netshort);

socket地址结构体,最终都是要强转成sockaddr类型(所有socket编程接口的参数类型)才可使用。

#include <bits/socket.h>
// 表示 socket 地址的 基础结构体
struct sockaddr{
    sa_family_t sa_family;
    char sa_data[14];
}
// Linux 下新定义 socket 地址,支持多数协议,且内存对齐
struct sockaddr_storage{
    sa_family_t sa_family;
    unsigned long __ss_align;
    char __ss_padding[128 - sizeof(__ss_align )];
}

// 下面是指定协议的专用结构体
// UNIX 本地域专用结构体
struct sockaddr_un{
    sa_family_t sin_family;     // 协议族: AF_UNIX
    char sun_path[108];         // 文件路径名
}
// IPv4 socket 结构体
struct in_addr{ u_int32_t s_addr; } // IPv4地址,网络字节序表示
struct sockaddr_in{
    sa_family_t sin_family;     // 协议: AF_INET
    u_int16_t sin_port;         // 端口号: 网络字节序表示
    struct in_addr sin_addr;    // IPv4地址结构体
}
// IPv6 socket 结构体 略

IP地址转换函数:

in_addr_t inet_addr(char *strptr); // 将 xx.xx.xx.xx 表示的IP地址,转换成 网络字节序 整数
int inet_aton(char *cp, struct in_addr *inp); // 功能同上
char *inet_ntoa( struct in_addr in ); // 功能相反

socket 基础 API

socket是可读、可写、可控制、可关闭的文件描述符。

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol); // return fd on success; -1 on error
// domain : PF_INET、PF_INET6、PF_UNIX
// type   : SOCK_STREAM 、SOCK_UGRAM

// 将一个 socket 绑定到某端口,通常服务端才需要这么做
int bind(int sockfd, struct sockaddr* my_addr, socklen_t addrlen); // 0 on success; -1 on error

// 在端口监听,建立监听队列
int listen( int sockfd, int backlog );
// backlog : 监听队列的长度

// 从监听队列中,选择一个进行链接,生成一个新的sockfd,标识这个链接
int accept(int sockfd, struct sockaddr *ret_addr, socklen_t *ret_len); // new sockfd;-1 on error
  • 服务端依次调用socket -> bind -> listen后阻塞,TCP链接就进入了LISTEN状态,直到收到客户端发送的syn包,listen返回。

  • 之后调用accept就是发送了ack+syn包,收到客户端ack包(三次握手结束)后返回新的sockfd标识这个TCP链接,进入ESTABLISHED状态。accpet是从监听队列(服务端)中取出链接,所以客户端如果提前退出、或网络断开,accept是完全不知情的,依然会当作正常情况来和客户端成功建立链接。

// 主动发送syn包,请求建立链接
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);

// sockfd 引用计数减一,变成0时,关闭链接,发送 FIN 包
int close( int sockfd );

// 立即关闭链接
int shutdown(int sockfd, int howto);
// howto: SHUT_RD、SHUT_WR、SHUT_RDWR

TCP流数据读写:

ssize_t send(int sockfd, void *buf, size_t len, int flags); // -1 on error

// 成功时返回实际读取的长度,可能小于我们期望的长度,所以要多次读取
ssize_t recv(int sockfd, void *buf, size_t len, int flags); // -1 on error

UDP数据报读写:

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

适用于TCPUDP的通用数据读写函数:

struct msghdr{
    void *msg_name;             // socket 地址
    socklen_t msg_namelen;      // socket 地址长度
    struct iovec* msg_iov;      // 分散的内存块
    int msg_iovlen;             // 内存块数量
    void *msg_control;          // 指向辅助数据的开始位置
    socketlen_t msg_controllen; // 辅助数据大小
    int msg_flags;
}
struct iovec{
    void *iov_base; // 内存开始地址
    size_t iov_len; // 内存长度
}
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr* msg, int flags);

MSG_OOB数据相关:

// 用于判断下一个读到的 数据包是否是 MSG_OOB 数据
int sockatmark( int sockfd ); // return 1 on oob data;
// 获取本地 socket 地址
int getsockname(int sockfd, struct sockaddr* address, socklen_t *address_len);

// 获取远端 socket 地址
int getpeername(int sockfd, struct sockaddr* address, socklen_t *address_len);

// 获取 socket 选项
int getsockopt(int sockfd, int level, int option_name, void *option_value, socklen_t *option_len);

// 设置 socket 选项
int setsockopt(int sockfd, int level, int option_name, void *option_value, socklen_t option_len);

网络信息 API

#include <netdb.h>
struct hostent{
    char *h_name;           // 主机名
    char **h_aliases;       // 主机别名列表
    int h_addrtype;         // 地址类型
    int h_length;           // 地址长度
    char **h_addr_list;     // 按网络字节序,列出主机IP地址列表
}
struct hostent *gethostbyname(char *name);
struct hostent *gethostbyaddr(void *addr, size_t len, int type);

struct servent{
    char *s_name;       // 服务名称
    char **s_aliases;   // 服务的别名列表
    int s_port;         // 端口号
    char *s_proto;      // 服务类型 tcp 或 udp
}

struct servent *getservbyname(char *name, char *proto);
struct servent *getservbyport(int port, char *proto);
struct addrinfo{
    int ai_flags;               //
    int ai_family;              // 地址族
    int ai_socktype;            // 服务类型 SOCK_STREAM 或 SOCK_DGRAM
    int ai_protocol;            //
    socklen_t ai_addrlen;       // socket 地址 ai_addr 的长度
    char *ai_canonname;         // 主机的别名
    struct sockaddr *ai_addr;   // 指向 socket 地址
    struct addrinfo *ai_next;   // 指向下一个 sockinfo 结构的对象
}
int getaddrinfo(char *hostname, char *service, struct addrinfo *hints, struct addrinfo **result);
void freeaddrinfo(struct addrinfo *res);

int getnameinfo(struct sockaddr* sockaddr, socklen_t addrlen, char *host, \
                socklen_t hostlen, char *serv, socklen_t servlen, int flags);

6. 高级 I/O 函数

管道实现进程间通信。fd[0]读取端,fd[1]写入端,默认情况下,读取与写入都是堵塞式的。

int pipe( int fd[2] );

只支持AF_UNIX的管道:

int socketpair(int domain, int type, int protocol, int fd[2]);

复制文件描述符:

int dup( int fd );
int dup2( int fd, int new_fd );

分散读与集中写:

ssize_t readv(int fd, struct iovec *vector, int count );
ssize_t writev(int fd, struct iovec *vector, int count );

零拷贝函数,直接在两个文件描述符之间直接传递数据(完全在内核中操作),效率很高:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

// 用于在两个文件描述符之间移动数据,也是零拷贝函数
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

// 也是在两个文件描述符之间复制数据,但是它不消耗数据
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

in_fd 必须是一个支持mmap函数的文件描述符(即它必须指向真实的文件),out_fd必须是一个socket,所以说,sendfile基本上是为网络上传输文件而设计的。

申请一段内存空间,将这段内存作为进程间通信的共享内存,也可以直接将文件映射到内存空间。

#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *start, size_t length); // 释放申请的内存空间

修改描述符属性和行为的函数:

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

7. Linux 服务器程序规范

日志

使用 Linux 自带的日志机制:

#include <syslog.h>
void syslog(int priority, char *message, ...);
void openlog(char *ident, int logopt, int facility);
int setlogmask(int maskpri);
void closelog();

用户信息

uid_t getuid();
uid_t geteuid();
gid_t getgid();
gid_t getegid();
int setuid(uid_t uid);
int seteuid(uid_t uid);
int setgid(gid_t gid);
int seregid(gid_t gid);

进程间关系

#include <unistd.h>
pid_t getpgid(pid_t pid);            // 获取进程组id
int setpgid(pid_t pid, pid_t pgid);  // 设置进程组id
pid_t setsid(void);                  // 当前进程脱离原会话,再新建一个新的会话
pid_t getsid(pid_t pid);             // 读取 sid

系统资源限制

struct rlimit{
    rlim_t rlim_cur;
    rlim_t rlim_max;
}
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, struct rlimit *rlim);

char *getcwd(char *buf, size_t size); // 获取当前目录
int chdir(char *path);                // 修改当前工作目录
int chroot(char *path);               // 修改进程根目录

// 创建守护进程
int daemon(int nochdir, int noclose);

8. 高性能服务器程序框架

服务器基本框架:

模块单机服务器程序服务器集群
I/O 处理单元处理客户链接,读取网络数据接入服务器,实现负载均衡
逻辑单元业务进程或线程逻辑服务器
存储单元本地数据库数据库服务器
请求队列各单元之间的通信各服务器之间的永久 TCP 链接

I/O 模型对比:

I/O 模型读写操作 和 阻塞阶段
阻塞 I/O程序阻塞于读写函数
I/O 复用阻塞于复用函数(select、epoll),由于可以监听多个 I/O 事件,所以对 I/O 本身的读写是非阻塞的
SIGIO 信号信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞
异步 I/O由内核执行读写操作,完成后触发读写完成事件,程序没有阻塞阶段

Reactor 模式

同步 I/O 模型常用于实现Reactor模式。

Reactor要求主线程只负责监听文件描述符上是否有事件发生,有的话立即将该事件通知逻辑单元(工作线程),除此之外,主线程不做任何其他实质性的工作。读写数据、接受新的链接、以及处理客户请求都在工作线程中完成。

流程如下(以epoll为例):

  • 主线程往epoll内核事件表中注册socket上的"读就绪"事件

  • 主线程调用epoll_wait等待socket上有数据可读

  • socket上有数据可读时,epoll_wait通知主线程。主线程将socket可读事件放入请求队列

  • 在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket的写就绪事件

  • 主线程调用epoll_wait等待socket可写

  • socket可写时,epoll_wait通知主线程,主线程将socket可写事件放入请求队列

  • 在请求队列上的某个睡眠的工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

请求队列中的事件都有自己的类型(可读事件、可写事件),所以不需要区分“读工作线程”和“写工作线程”,只使用一种类型的线程,区分处理就好。

Proactor 模式

异步 I/O 模型用于实现Proactor模式,但是也可以使用同步 I/O 方式,模拟出Proactor模式。

Proactor模式,将所有 I/O 操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

流程如下(以aio_readaio_write为例):

  • 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。

  • 主线程继续处理其他逻辑

  • socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据可用。

  • 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。

  • 主线程继续处理其他逻辑

  • 当用户缓冲区数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。

  • 应用程序预先定义好的信号处理函数,选择一个工作线程来做善后处理,比如决定是否关闭socket

注意,主线程中的epoll_wait调用仅能用来检测监听socket上的连接请求事件,而不能用来检测socket上的读写事件。

模拟 Proactor 模式

模拟的原理是:主线程执行数据读写操作,读写操作完成后,由主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来说,它就直接获得的数据读写完成的结果,对“读出的数据”,它可以进行逻辑处理,对“写完的数据”,它可以做善后处理。

流程如下(以epoll_wait为例):

  • 主线程往epoll内核事件表中注册socket上的读就绪事件

  • 主线程调用epoll_wait等待socket上有数据可读

  • socket上有数据可读时,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。

  • 在请求队列上的某个睡眠的工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中,注册socket上的写就绪事件。

  • 主线程调用epoll_wait等待socket可写

  • socket可写时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。

两种高效的并发模式

如果 CPU 计算密集型程序,使用并发编程 反而 会因为任务切换导致效率降低。如果是 IO 密集型程序,由于 IO 操作的速度远慢于 CPU 计算,如果单线程序阻塞于 IO 操作,则此时 CPU 处于等待状态(浪费了 CPU 的计算能力)。所以,如果程序有多个线程,则被 IO 阻塞的执行线程会主动放弃 CPU,让给其他线程,这样显著提升了 CPU 的利用率。

半同步/半异步 并发模式

在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行,“异步”指的是程序的执行需要由系统事件(中断、信号)来驱动。

如上图所示,左边为“同步”代码,它的逻辑简单,效率相对较低、实时性较差;右边为“异步”代码,(信号中断)读操作不可预测、难以调试,但它的实时性好、效率高。

在服务器程序中,我们结合使用这两种模式的代码,逻辑单元使用“同步”代码线程,IO 单元使用“异步”代码线程。异步线程监听到客户请求后,就将其封装成请求对象,并插入到请求队列中。多个工作在同步模式下的工作线程(逻辑单元)从请求队列中取出事件并处理。具体选择哪个工作线程来处理队列里的事件,则取决于请求队列的设计(比如最简单的轮询Round Robin)。

领导者/追随者 并发模式

9. I/O 复用

第一种方法就是最传统的多进程并发模型 (每进来一个新的 I/O 流会分配一个新的进程管理。)第二种方法就是 I/O 多路复用 (单个线程,通过记录跟踪每个 I/O 流(sock)的状态,来同时管理多个 I/O 流 。)其实“I/O 多路复用”这个坑爹翻译可能是这个概念在中文里面如此难理解的原因。所谓的 I/O 多路复用在英文中其实叫 I/O multiplexing. 如果你搜索 multiplexing 啥意思,基本上都会出这个图:

于是大部分人都直接联想到"一根网线,多个 sock 复用" 这个概念,包括上面的几个回答, 其实不管你用多进程还是 I/O 多路复用, 网线都只有一根好伐。多个 Sock 复用一根网线这个功能是在内核+驱动层实现的。
重要的事情再说一遍: I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个 Sock(I/O 流)的状态(对应空管塔里面的 Fight progress strip 槽)来同时管理多个 I/O 流. 发明它的原因,是尽量多的提高服务器的吞吐能力。

ngnix 会有很多链接进来, epoll 会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。

select API (1983 年实现)

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
FD_ZERO(fd_set *fdset);
FD_SET(int fd, fd_set *fdset);
FD_CLR(int fd, fd_set *fdset);
int FD_ISSET(int fd, fd_set *fdset);
struct timeval{
    long tv_sec;
    long tv_usec;
}

fd_set结构体是一个整形数组,数组每一位标记一个文件描述符,数组容量由FD_SETSIZE指定。使用宏FD_ZERO等来操作fd_set中的位。

select成功时,返回就绪(可读、可写、异常)文件描述符的总数。如果超过了timeval时间,没有任何描述符就位,则返回0;失败返回-1,并设置errno。如果在select等待期间,程序如果收到信号,则select立即返回-1,并设置errnoEINTR

select缺点:

  • select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。

  • select 如果任何一个 sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个 sock 上有数据,于是你只能自己一个一个的找,10 几个 sock 可能还好,要是几万的 sock 每次都找一遍,这个无谓的开销就颇有海天盛筵的豪气了。

  • select 只能监视 1024 个链接, 这个跟草榴没啥关系哦,linux 定义在头文件中的,参见 FD_SETSIZE。

  • select 不是线程安全的,如果你把一个 sock 加入到 select, 然后突然另外一个线程发现,尼玛,这个 sock 不用,要收回。对不起,这个 select 不支持的,如果你丧心病狂的竟然关掉这个 sock, select 的标准行为是不可预测的, 这个可是写在文档中的哦.

poll API (1997 年实现)

select调用区别不大,只是把内部实现由fd_set改为了链表而已。

poll缺点:

  • poll 去掉了 1024 个链接的限制,于是要多少链接呢, 主人你开心就好。

  • poll 从设计上来说,不再修改传入数组

epoll API (2002 年实现)

epoll在实现与使用上,与selectpoll有很大差异。selectpoll都是单个函数,而epoll使用一组函数来完成任务。

epoll将文件描述符上的事件放在内核里的一个事件表中,从而无需像selectpoll那样每次调用都要重复传入文件描述符(或事件集)。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。该文件描述符通过epoll_create(int size)创建,之后所有的epoll调用都要指定该文件描述符。

关于 ET 和 LT 原理的系列文章 非常值得一读

#include <sys/epoll.h>
int epoll_create(int size);

操作epoll的内核事件表:

int epoll_wait(int epfd, int op, int fd, struct epoll_event *event);
// op : EPOLL_CTL_ADD 、 EPOLL_CTL_MOD 、EPOLL_CTL_DEL
struct epoll_event{
    _uint32_t events;   // epoll 事件
    epoll_data_t data;  // 用户数据
}
typedef union epoll_data{
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

fd是要操作的文件描述符,op是指定操作类型,event指定事件。

// 成功时返回就绪的文件描述符个数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中,这个数组只用于输出epoll_wait检测到的就绪事件,而不像selectpoll那样即用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引文件描述符的效率。

// 使用 poll 必须遍历所有已注册文件描述符,并找到其中的就绪者
int ret = poll(fds, MAX_EVENT_NUMBER, -1);
for(int i = 0; i < MAX_EVENT_NUMBER; ++i){
    if(fds[i].revents & POLLIN){
        int sockfd = fds[i].fd;
        // 处理 sockfd
    }
}

// 使用epoll,则直接使用即可
int ret = epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1);
for(int i = 0; i < ret; i++>){
    int sockfd = events[i].data.fd;
    // 处理sockfd
}