linux高性能服务器编程
来源: swoole - 学习Swoole需要掌握哪些基础知识: http://wiki.swoole.com/wiki/page/487.html - 评论君
推荐书籍: <构建高可用linux服务器> <设计原本> <领域特定语言> <代码之殇>
1-4 tcp/ip 协议簇 与 各种重要网络协议
tpc/ip 协议簇
tcp/ip 协议簇:
数据链路层: ARP + RARP -> ip / 机器物理地址 相互转换
网络层: 数据包的选路和转发(逐跳通信); ip -> 根据数据包的目的ip地址选择如何投递; icmp -> 检测网络连接p
传输层: 端到端(end to end)通信; tcp + udp + sctp
应用层: 处理应用程序逻辑; ping telnet ospf dns
封装:
tcp 报文封装:
应用层 -> send/write -> 传输层: tcp 报文/ udp 数据包(datagram) -> 网络层: ip 数据报 -> 数据链路层: 帧(frame, 帧的最大传输单元 max transmit unit, MTU)
分用: 数据链路层 -> 应用层 的过程中, 每层交界都面对多个不同的协议, 根据数据报头部的字段需要分发给哪个协议来继续执行
MTU: 可以使用 ifconfig/netstat 查看
ARP: ip地址/物理地址 相互映射; 维护一个高速缓存, 包含 经常访问/最近访问
DNS: 域名/ip 相互映射, 协议中包含查询类型, 比如 cname
# arparp -d 192.168.1.2 # 清除缓存tcpdump -i eth0 -ent '(dst 192.168.1.2 and src 192.168.1.3)or(dst 192.168.1.3 and src 192.168.1.2)' # 在 1.3 上面抓包telnet 192.168.1.2 echo # 用 1.3 来连接 1.2, 这样 tcpdump 中就可以抓到 arp 包了# dns/etc/resolv.conf # dns 服务器的iphost -t A baidu.com # 查询 baidu.com 的 iptcpdump -i eth0 -nt -s 500 port domain # 使用 port domain 过滤, 只使用域名服务的包ip 协议
为上层提供 无状态/无连接/不可靠 服务
无状态: 数据报 相互独立/没有上下文关系 -> 无法 处理乱序/重复; 简单/高效
无连接: ip通信双方都不长久的维持双方的任何信息, 上层协议需要指明ip地址
不可靠: 不能保证ip数据报准确到达
ipv4头部结构:
服务类型: 最小延时(ssh/telnet) 最大吞吐量(ftp) 最高可靠性 最小费用
ip分片: ip数据报 > MTU

查看路由表: netstat/route
ip 转发: 主机一般只能 发送/接收 数据报, 配置在 /proc/sys/net/ipv4/ip_forward
重定向: icmp 重定向报文; 主机重定向
ipv6: ipv4 地址不够用; 多播和流 / 自动配置, 便于管理 / 网络安全功能
tcpdump -ntx -i lo # 抓取本地回路上的数据包, -x 输出数据包的二进制码telnet 127.0.0.1tcpdump -ntv -i eth0 icmp # 只抓取 icmp 报文ping baidu.com -s 1473 # -s 指定发送数据字节大小route add -host 192.168.1.2 dev eth0 # 发送到 1.2 的数据包直接结果 eth0 传送route del -net 192.168.1.0 netmask 255.255.255.0 # 无法访问同一个局域网的其他机器route del default # 删除默认路由, 结果就是无法访问外网了route add defalut gw 192.168.1.2 dev eth0 # 重设默认路由, 但是将网关设为某台主机, 而非路由route -Cn # 查看路由缓冲tcp 协议
tcp vs udp: 面向连接 字节流 可靠传输
tcp: 建立连接(一对一, 无法广播和多播) -> 分配必要内核资源以管理连接状态和连接上的数据传输 -> 全双工 -> 都必须断开连接释放系统资源
MSS: max segment size, 最大报文长度, 通常设置为 MTU-40
半关闭状态: 发送 FIN 并得到确认, 就由 连接状态 进入 半关闭状态, 此时还可以接受对方的数据, 直到对方发送 FIN, 连接关闭
断线重连: 次数由 /proc/sys/net/ipv4/tcp_syn_retries 配置, 由1s开始, 每次重试时间 x2
复位报文段(RST): 访问不存在的端口; 异常中止连接; 处理半打开连接
按照数据长度: 交互数据(ssh/telnet, nagle算法, 通信双方任何时刻都最多只能发送一个未被确认的tcp报文段) 块状数据(ftp)
超时重传: 超时时间 + 重传次数
拥塞控制: 慢启动(slow start) 拥塞避免(congestion avoidance) 快速重传(fast retransmit) 快速恢复(fast recovery); 算法 -> reno vegas cubic

udp: 非常适合做广播和多播

tcpdump -i eth0 -nt '(dst 192.168.1.2 and src 192.168.1.3)or(dst 192.168.1.3 and src 192.168.1.2)' # 查看 tcp 连接的 建立/关闭telnet 192.168.1.2 80nc -p 12345 192.168.1.2 80 # 测试 tcp TIME_WAIT 状态ctrl-c # 中断连接nc -p 12345 192.168.1.2 80 # 重新建立, 显示连接失败netstat -nat # 查看连接状态, 此时就处于 TIME_WAIT 状态iperf -s # 1.2, iperf 是一个衡量网络状况的工具, -s 表示作为服务器运行, 默认监听5001端口, 并丢弃该端口接受的所有数据telnet 192.168.1.2 5001 # 1.3 连接 iperftcpdump -n -i eth0 port 5001tcp/ip 通信案例: 访问 Internet 上的 web 服务器
正向代理: 客户端配置, 通过正向代理访问其他网络
反向代理: 服务器配置, 接收用户请求, 分发给其他服务器处理
tcpdump 抓一次 wget: 代理服务器 -> dns 服务器(dns); 代理服务器 -> 查询路由器MAC地址(arp); wegt -> 代理服务器(http); 代理服务器 -> web 服务器(http)
http 请求(request): 请求行(请求方法 + 资源地址) + 请求头部(header) + 空行(<CR><LF>, 标识头部结束)
http 应答(response): 状态行 + 应答头部
http 无状态 -> cookie -> 标识 不同客户端
/etc/init.d/ # 服务器程序存放地址service start|stop|restart xxx # 服务管理/etc/hosts # dns 查询 Internet 上的域名, 本地名称查询使用 hosts 文件/etc/host.conf # 自定义系统解析主机名 order hosts,bind # 先本地, 后dns multi on # 允许匹配到多个ip高性能服务器编程
C语言函数常见套路: 成功返回 0, 失败返回 -1 并设置 errno; 使用 bit 里标识状态(节约空间, 位运算也更快), 然后使用 掩码 来改变值(位运算, 比如 改变状态/ip地址转换); 使用正负来处理有限状态, 避免使用更多参数
linux 网络编程基础 api
字节序: cpu累加器 一般能加载超过一个字节, 所以字节的顺序, 会影响加载的整数的值
大端序(big endian): 高位存储在高地址; 网络; java虚拟机
小端序(little endian): 高位存储在低地址; 现代 pc 大部分采取
协议族(protocol family) + 地址族(address family): unix(本地协议族) inet inet6
ip 地址转换函数: char <-> int
socket 选项
inet_aton() inet_ntoa() # ipv4inet_pton() inet_ntop() # ipv4 + ipv6int socket(int domain, int type, int protocol) # 创建socket; domain->协议族 type->流/数据报 protocol->0(默认); 返回 socket 文件描述法int bind() # 命名socket, 绑定到地址族中的具体socket地址int listen(int socketfd, int backlog) # 监听socket, 不能马上接听客户连接, 要创建一个监听队列来存放待处理客户连接int accept() # 从监听队列接受一个连接int connect() # 客户端主动与服务器建立连接int close(int fd) # 将 fd 引用计数减一int shutdown(int socketfd, int howto) # 关闭 socket 的行为: r/wssize_t recv(int socketfd, void *buf, size_t len, int flags) # 读取ssize_t send(int socketfd, const void *buf, size_t len, int flags) # 发送recvfrom() sendto() # udprecvmsg() sendmsg() # 通用: tcp + udpsocketmark(int socketfd) # tcp 带外数据接收方法getsockname() / getpeername() # 获取一个socket的 本/远 端socket地址getsocketopt() / setsocketopt() # 获取/设置 socket 文件描述符gethostbyname() / gethostbyaddr()getservbyname() / getservbyport()getaddrinfo() # gethostbyname() + getservbyname()getnameinfo() # gethostbyaddr() + getservbyaddr()高级 io 函数
fcntl(file control) 函数: 提供了对 fd 的各种控制操作; 通常用来将一个 fd 设置为 非阻塞
int pipe(int fd[2]) # 创建一个管道, 以实现进程间通信; 单向, read/write 阻塞; 建立的管道也有数据大小int dup(int file_descriptor) # stdin -> 文件 / stdout -> 网络连接(如: cgi编程)int dup2(int file_descriptor, int file_descriptor_two)ssize_t readv(int fd, const struct iovec* vector, int count) # 分散写ssize_t writev(int fd, const struct iovec* vector, int count) # 集中读sendfile() # 在2个文件描述符之间直接传递数据(完全在内核中), 避免内核缓冲区和用户缓冲区之间的数据拷贝 -> 零拷贝mmap() # 申请一段内存 -> 进程间通信的共享内存 / 直接将文件映射到其中munmap() # 释放由 mmap() 创建的内存splice() # 在2个 fd 之间移动数据, 也是 零拷贝tee() # 2个 管道fd 之间复制数据, 也是 零拷贝int fcntl(int fd, int cmd, ...) # fcntl 函数linux 服务器程序规范
一般以后台进程形式运行(也称为守护进程 daemon): 没有控制终端, 因而不会意外接受用户输入; 父进程通常为 init 进程(pid=1)
通常有一套日志系统 -> 文件 / udp服务器 / /var/log 下拥有自己日志目录
一般以某个非root用户运行: mysqld -> mysql; httpd -> apche; syslogd -> syslog
通常是可配置的 -> /etc
通常会在启动时生成一个 pid 文件并存入 /var/run 目录中, 比如 syslogd -> /var/run/syslogd.pid
通常需要考虑 系统资源和限制, 以预测自身能承受多大负荷, 比如 fd总数/内存
linux 系统日志: rsyslogd
用户信息: 大部分服务器程序以 root 启动, 但不以 root 运行; uid euid gid egid
euid: 使得运行用户拥有改程序的有效用户的权限, 方便资源访问; 比如 su 程序被设置了 set-user-id 标记
进程间关系: 进程组(pgid, 每个进程都隶属一个进程组); 会话(session, 一些有关联的会话形成一个会话)
系统资源: 物理设备(cpu, 内存) 系统策略限制(cpu时间) 具体实现限制(文件名长度限制)
void syslog(int priority, const char* message, ...) # 和 rsyslosd 通信priority:LOG_EMEGE 0 系统不可用LOG_ALERT 1 报警, 需要立即采取行动LOG_CRIT 2 非常严重的情况LOG_ERR 3 错误LOG_WARNING 4 警告LOG_NOTICE 5 通知LOG_INFO 6 信息LOG_DEBUG 7 调试void openlog(const char* ident, int logopt, int facility) # 改变 syslog 默认的输出方式, 进一步结构化日志内容logopt:LOG_PID 0x01 在日志消息中包含程序 pidLOG_CONS 0x02 如果无法记录到日志文件, 则打印到终端LOG_ODELAY 0x04 延迟打开日志功能, 直到第一次调用 syslogLOG_NDELAY 0x08 不延迟打开日志功能int setlogmask(int maskpri) # 简单设置日志掩码, 使日志级别大于日志掩码的日志信息被系统忽略int closelog()getuid() / setuid() # 用户身份相关函数getpgid() / setpgid() # 进程组setsid() / getsid() # 会话, 使用调用进程 pid 作为 sidps -o pid,ppid,pgid,sid,comm | less # 使用 ps 查看setrlimit() / getrilimit() # 系统资源getcwd() / chdir() / chroot() # 获取工作目录 / 改变进程工作目录 / 改变进程根目录int daemon(int nochdir, int noclose) # 服务器程序后台化高性能服务器程序框架
3个主要模块: io 处理单元(4种io处理模式+2种高效事件处理模式); 逻辑单元(2种高效并发模式+有限状态机); 存储单元(可选, 和网络编程无关)
c/s模型: client-server, server为中心

p2p(peer to peer, 点对点)模型: 优点 -> 每台机器消耗服务的同时也给别人提供服务; 缺点 -> 用户之间传输的请求过多时, 网络负载将加重 / 主机之间很难互相发现, 需要带有一个发现服务器; 其实每个点既是 服务器 也是 客户端, 也是采用 c/s 模型实现
io处理单元: 服务器管理客户连接
逻辑单元: 通常一个 进程/线程, 分析并处理客户数据, 然后将结果传递给 io 处理单元
网络存储单元: DB / cache / file
请求队列: 各单元通信的抽象

4种io模型
socket 基本api中可能被阻塞的系统调用: accept send recv connect
非阻塞io通常要和其他其他io通知机制一起使用, 比如 io复用 / sigio信号
io复用(最常使用): 应用程序通过 io复用函数 向内核注册一组事件, 内核通过 io复用函数 把其中的就绪事件通知给应用程序
linux常用 io复用函数: select poll epoll_wait; 本身是阻塞的, 具有同时监听多个io事件的能力
sigio 信号: 为一个 fd 指定 宿主进程 -> fd 上有事件发生 -> sigio 信号处理函数被触发 -> 被指定的宿主程序捕获到 sigio信号
理论上 阻塞io / io复用 / 信号驱动io 都是 同步io模型: 先 io(就绪)事件, 后 io读写
异步io(aio.h): 用户直接对io执行读写操作 -> 内核完成io操作 -> 通知应用程序 io完成事件
同步 vs 异步: 内核向应用程序通知的时间(就绪事件 vs 完成时间) 由谁来完成io读写(应用程序 vs 内核)
2种高效事件处理模式
reactor模式(同步io): 主线程(io处理单元) 只负责监听 fd 上的事件, 有就通知 工作线程(逻辑单元), 不做其他实质性工作; 工作线程完成 读写数据/接受连接/处理连接 等

proactor模式(异步io): 所有io操作交给主线程和内核, 工作线程只负责业务逻辑; 更符合服务器编程框架


2种高效并发模式
并发编程: 如果是计算密集型, 并发编程没有优势, 反而由于任务切换使效率降低; io密集型, io操作速度 远小于 cpu计算速度
半同步/半异步模式: 同步 -> 程序完全按照代码顺序执行, 异步 -> 程序执行由系统事件(中断/信号)来驱动; 同步 -> 逻辑单元, 异步 -> io单元
半同步/半反应堆(half-sync/half-reactive)模式: 主线程(异步, 监听/连接 socket) -> 请求队列 -> 工作线程(获取连接socket); 缺点 -> 共享请求队列, 需要加锁 / 每个工作线程同一时间只能处理一个客户请求, 增加工作进程会增加工作线程切换开销
高效 半同步/半异步模式: 主线程 只监听socket -> 派发新请求给 工作进程 -> 工作进程 连接socket/处理io/维持自己的事件循环
领导者/追随者模式: 多个工作现成轮流获得事件源集合, 轮流监听/分发并处理事件; 1个领导者 + 多个追随者(线程池, 休眠) -> 领导者监听到io事件 -> 自己处理io事件/线程池中选出新的领导者

有限状态机 finite state machine
提供服务器性能的其他建议
池(pool): 服务器硬件资源相对 充裕 -> 空间换时间; 一组资源集合, 服务器启动之初就完全创建并初始化, 这样就成为了 静态资源, 服务器正式运行时, 需要相关资源可以直接从池中获取, 无须动态分配, 使用完后可以直接把资源放回池中, 无须执行系统调用来释放资源; 分配 足够多 + 动态分配; 内存池 / 进程池 / 线程池 / 连接池
内存池: socket 接收缓存/发送缓存
进程池/线程池 -> 并发编程, 无须调用 fork/pthread_create
连接池: 服务器/服务器机群的内部永久连接, 比如 db连接池
数据复制: 避免不必要的数据复制
内存缓冲区 -> 用户程序缓冲区: 内核 直接处理, 比如 ftp服务器中使用 零拷贝 函数 sendfile()
用户代码内 -> 比如2个进程间要传递大量数据, 应该考虑 共享内存, 而不是 管道/消息队列
上线文切换(context switch): 进程切换/线程切换 导致的 系统开销
共享资源加锁保护: 锁 -> 不处理任何业务逻辑, 而且需要访问内核资源 -> 如果有更好的解决方案, 就应该避免使用锁 / 如果必须使用锁, 应尽量减少锁的粒度
io复用
io复用: 程序同时监听多个 fd; 本身是阻塞的
使用 io 复用的场景:
- client需要同时监听多个 socket
- client需要同时处理用户输入和网络连接
- tcp server需要同时处理 socket 监听/连接 -> io复用最多的场合
- server 需要同时处理 tcp/udp, 比如 回射 server
- server 需要 监听多个端口/处理多个服务, 比如 xinetd server
select 系统调用用途: 在一段指定时间内, 监听用户感兴趣的 fd 上的 read/write/exception 事件; fd 就绪条件: 可读 -> balabala; 可写 -> balabala; 处理带外数据
poll 系统调用: 和 select 类似, 在一段时间内轮询一定数量的 fd, 以测试其中是否有就绪者
epoll 系统调用
使用一组函数来完成
把用户关心的 fd 上的事件放在内核的一个事件表中
LT(level trigger, 电平触发): 默认, 相当于一个效率较高的 poll; epoll_wait 检测到事件时就通知应用程序, 应用程序可以不立即处理(因为会重复通知)
ET(edge trigger, 边沿触发): epoll的高效工作模式; epoll_wait 检测到事件时就通知应用程序, 应用程序必须立即处理(因为后序不会再通知), 减低了同一个 epoll 事件被重复触发的次数
EPOLLONESHOT 事件: 一个 socket 连接在任一时刻都只被一个线程处理; 保证了连接完整性, 避免很多可能的竞态条件
int epoll_create(int size) # 创建额外的 fd, 用来标识内核中的事件表int epoll_ctl() # 操作内核事件表int epoll_wait() # 在一段超时时间内等待一组 fd 上的事件 -> 只传递就绪事件, 不处理用户注册的事件
信号
用户/系统/进程 发送给目标进程的信息, 以通知目标进程 某个状态的改变/系统异常
被挂起的信号: 设置进程信号掩码 -> 屏蔽信号(程序不用处理所有的信号)
统一事件源: 信号事件/io事件一样被处理; 信号事件 -> 管道 -> 监听管道读端fd 上的可读事件 -> io事件; 如 libevent 库
linux信号可由如下条件产生:
- 对于前台进程, 可以通过输入特殊的终端字符来发送信号, 如 Ctrl-C 通常会发送一个中断信号
- 系统异常, 如 浮点异常/非法内存段访问
- 系统状态变化, 如 alarm定时器到期 -> SIGALRM 信号
- 运行kill命令/调用kill函数
int kill(pid_t pid, int sig) # 发送信号; pid -> pid/本进程组/除init进程外/其他进程组; sig -> 都大于0int signal() # 为一个信号设置处理函数int sigaction() # 更健壮的接口int sigpending() # 获得当前进程被挂起的信号网络编程相关信号:
- SIGHUP: 挂起进程的控制终端; 没有控制终端的网络后台程序 -> 强制服务器重读配置文件
- SIGPIPE: 向 读端关闭的 管道/socket 写数据 -> 默认关闭进程 -> 不希望错误的写操作而导致程序退出 -> 代码中捕获并处理该信号/至少忽略
- SIGURG: 内核通知应用程序带外数据到达 -> io复用/SIGURG信号
定时器
需要处理的第三类事件 - 定时事件: 如 定时检测一个客户连接的活动状态; 有效组织, 预期触发 + 不影响主要逻辑
定时事件 -> 封装成 定时器 -> 使用某种容器类数据结构(2种高效管理定时器的容器: 时间轮/时间堆) -> 将所有定时器串联起来 -> 实现对定时事件的统一管理
linux 3种定时方法: socket选项 SO_RECVTIEMO/SO_SENDTIEMO; SIGALRM 信号; io复用超时参数
定时器至少包含2个成员: 超时时间(绝对/相对) + 任务回调函数
定时tick: 使用固定频率心搏函数tick -> 依次检测到期定时器 -> 执行定时器上的回调函数(时间轮)
最短时间tick: 每次使用最小定时器超时值作为tick -> tick调用 -> 最小定时器被调用 -> 更新剩余定时器(时间堆)
简单时间轮: 每个槽(slot)用有相同的槽间隔si(slot interval, 其实就是心搏时间tick); 每个槽放在一个 定时器链表, 新的定时器分配通过定时时间hash到不同的槽
复杂时间轮 -> 类似 水表, 有不同精度的轮子
时间堆: 最小堆实现
高性能io框架库 - Libevent
linux服务器必须处理的3类事件: io事件/信号/定时事件
处理事件需要考虑的问题: 统一事件源 -> io复用; 可移植性; 对并发编程的支持, 避免竞态条件
句柄(handle): 统一事件源 -> 绑定句柄 -> 内核检测到就绪事件 -> 通过句柄通知应用程序
事件循环: 无法预知客户 连接请求/暂停信号 -> 循环等待并处理
事件多路分发器(EventDemultiplexer): 封装各种 io复用系统 为统一的接口
具体事件处理器: 框架提供一个接口, 应用程序来自己扩展
Libevent 源码分析:
- 跨平台
- 统一事件源
- 线程安全, 使用 libevent_pthreads
- 基于 Reactor 模式实现
- 编写产品级函数库需要考虑哪些细节
- 提高C语言功底: 大量函数指针 + 多态机制 + 一些基础数据结构的高效实现