高性能服务器编程
网络编程基础API
socket
地址结构体,最终都是要强转成sockaddr
类型(所有socket
编程接口的参数类型)才可使用。
// 表示 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]; // 文件路径名
}
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地址结构体
}
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 ); // 功能相反
unsigned long htonl(unsigned long hostlong); // 主机字节序 to 网络字节序
unsigned short htons(unsigned short int hostshort);
unsigned long ntohl(unsigned long netlong); // network to host long
unsigned short ntohs(unsigned short netshort);
socket 基础 API
socket
是可读、可写、可控制、可关闭的文件描述符。
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);
适用于TCP
与UDP
的通用数据读写函数:
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, ...);
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);
进程间关系
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_read
和aio_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 单元使用“异步”代码线程。异步线程监听到客户请求后,就将其封装成请求对象,并插入到请求队列中。多个工作在同步模式下的工作线程(逻辑单元)从请求队列中取出事件并处理。具体选择哪个工作线程来处理队列里的事件,则取决于请求队列的设计(比如最简单的轮询)。
领导者/追随者 并发模式
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
,并设置errno
为EINTR
。
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
在实现与使用上,与select
和poll
有很大差异。select
与poll
都是单个函数,而epoll
使用一组函数来完成任务。
epoll
将文件描述符上的事件放在内核里的一个事件表中,从而无需像select
与poll
那样每次调用都要重复传入文件描述符(或事件集)。但epoll
需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。该文件描述符通过epoll_create(int size)
创建,之后所有的epoll
调用都要指定该文件描述符。
#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
检测到的就绪事件,而不像select
和poll
那样即用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引文件描述符的效率。
// 使用 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
}