Linux高性能服务器编程笔记

阅读《Linux高性能服务器编程》时记录下的笔记

/proc/sys/net/ipv4/下定义了大量tcp连接相关的内核变量。

一些常用的工具:tcpdump、iptables、telnet、nc、netstat、iperf、squid

1
2
nc -p client_port server_ip server_port # 建立连接
nc -l port # 监听port
1
tcpdump -n -i 网卡名 port 端口号 # -t会关闭时间戳
1
netstat -nat # 查看连接状态
1
iperf -s # 测量网络状况的工具,-s将其作为服务器运行,默认监听5001端口,并丢弃收到的所有数据

squid是代理服务器,支持正向代理、反向代理

/etc/init.d/目录下有众多服务器程序,如httpd、vsftpd、sshd、mysqld,由脚本程序service(/usr/sbin/service)提供统一管理(start, stop, restart)

1
arp -d target_ip # 删除ARP高速缓存中target_ip的MAC地址

tcp状态转移:

客户端执行半关闭后(FIN_WAIT_2),未等服务器关闭连接就强行退出,此时客户端连接由内核来接管,称为孤儿连接。Linux内核变量定义了最大孤儿连接数(tcp_max_orphans)和最长停留时间(tcp_fin_timeout)

主动断开连接的服务器会由于处于TIME_WAIT状态而不能在原端口(服务器往往运行在知名端口)立即重启,可以通过socket选项SO_REUSEADDR来强制进程立即使用处于TIME_WAIT状态的连接占用的端口。

TCP传输的紧急数据往往又称带外数据(Out Of Band, OOB),紧急指针只会指向紧急数据的下一字节,所以只有带外数据的最后一个字节会作为紧急数据。通常情况下,带外数据存储在特殊的缓存中,带外缓存只有1字节,如果设置SO_OOBINLINE则带外数据将和普通数据一样被存放在TCP接收缓冲区。

Linux中两个TCP超时重传的内核参数tcp_retries1(最少重传次数)、tcp_retries2(最多重传次数)

拥塞控制

接收方通过发送窗口(SWND)控制发送方发送的报文段数量,发送方通过接收通告窗口(RWND)控制发送方的SWND。发送方还有拥塞窗口(CWND),SWND=min(RWND, CWND),这些都以字节为单位。

在使用DNS服务之前,Linux会先进行本地查询,在/etc/hosts配置文件中查找主机名对应的IP地址。

/etc/host.conf文件可以自定义系统解析主机名的方法和顺序

1
2
order hosts,bind # hosts表示/etc/hosts,bind表示DNS服务
multi on

Linux网络编程

现代PC大多采用小端字节序,所以小端字节序又称为主机字节序。发送端总是将数据转为大端字节序,所以大端字节序也称为网络字节序。

linux提供一系列主机字节序和网络字节序间转换的函数。

1
2
3
4
5
6
#include <netinet/in.h>
// h: host, n: net, l: long, s: short
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);

Linux将许多东西都看作文件进行处理,socket也是如此。

Socket

socket网络编程中通用biao'ssocket地址的是结构体sockaddr

1
2
3
4
5
6
7
#include <bits/socket.h>
struct sockaddr
{
sa_family_t sa_family; // 所属地址族,如AF_UNIX、AF_INET、AF_INET6
// 协议族PF_*和地址族完全对应,可以混用
char sa_data[14]; // 地址,根据地址族类型有不同含义
};

由于14字节的sa_data无法满足多数需求,Linux定义了新的socket地址结构体。

1
2
3
4
5
6
struct sockaddr_storage
{
sa_family_t sa_family;
unsigned long int __ss_align; // 仅用作地址对齐
char __ss_padding[128-sizeof(__ss_align)];
};

为方便使用,Linux提供了协议族的专用socket地址——sockaddr_un(UNIX本地协议族), sockaddr_in(IPv4,#include <netinet/in.h>), sockaddr_in6(IPv6),它们的port应用大端格式(n)

IP地址转换函数

ipv4有以下函数(a表示字符串,n表示二进制数)

1
2
3
4
#include <arpa/inet.h>
in_addr_t inet_addr(const char* strptr);
int inet_aton(const char* cp, struct in_addr* inp);
char* inet_ntoa(struct in_addr in);

需要注意的是inet_ntoa返回的是其内部静态变量的指针,所以是不可重入的。

有同时支持IPv4,IPv6的函数(p表示字符串地址,n表示二进制数,af为地址族)

1
2
3
#include <arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);

inet_pton成功时返回1,失败返回0并设置errno。

inet_ntop成功时返回dst地址,失败则返回NULL并设置errno。cnt用于指定dst的大小,这里往往使用以下宏

1
2
3
#include <netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

创建socket

1
2
3
4
5
6
7
8
9
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
/*
* domain: 地址族
* type: 是服务类型,如SOCK_STREAM,SOCK_UGRAM,对于TCP/IP协议族,SOCK_STREAM表示传输层使用TCP,SOCK_UGRAM表示传输层使用UTP,可以与SOCK_NONBLOCK, SOCK_CLOEXEC相与控制是否阻塞以及fork创建子进程时子进程是否关闭该socket
* protocol: 在前两个参数构成的协议集合下,再选择一个具体的协议。由于前两个基本已经确定了具体的协议,所以几乎都用0(表默认)
* return: 文件描述符。失败则返回-1并设置errno
*/

命名socket

将socket与socket地址绑定称为给socket命名。通常只有服务器需要命名socket,而客户端采用匿名方式,由操作系统自动分配。

1
2
3
4
5
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
// 成功时返回0,失败则返回-1并设置errno
// 常见errno有EACCES(被绑定地址是受保护地址,比如知名服务端口), EADDRINUSE(被绑定地址正在使用中,比如处于TIME_WAIT)

监听socket

1
2
3
4
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// backlog指定内核监听队列的最大长度。如果监听队列长度超过backlog,服务器将不受理新的客户连接,客户端也将受到ECONNREFUSED错误信息。在内核版本2.2之前,backlog指的是所有处于半连接(SYN_RCVD)和完全连接的socket上限,之后则表示处于完全连接的socket上限,处于半连接的socket上限由/proc/sys/net/ipv4/tcp_max_syn_backlog定义。backlog的典型值为5。返回值同上。
// 实际实现中,监听队列的最大长度会略大于backlog

服务端开始监听后,客户端就可以与服务端建立TCP连接。

接收连接

1
2
3
4
5
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 用于获取客户端socket地址。实际工作过程是从监听队列中取出连接,并不涉及到网络
// 返回值同上

发起连接

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, constr struct sockaddr *serv_addr, socklen_t addrlen);
// 常见errno有ECONNREFUSED(目标端口不存在,连接被拒绝)、ETIMEDOUT(连接超时)

关闭连接

1
2
3
4
5
6
7
#include <unistd.h>
int close(int fd);
// 将fd的引用-1,如果为0则关闭socket

// 如果无论如何都要立即关闭socket,应使用shutdown
#include <sys/socket.h>
int shutdown(int sockfd, int howto);

数据读写

TCP
1
2
3
4
5
6
7
8
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 读取sockfd上的数据,返回实际读取数据长度(<=len)
// len通常为buf大小-1
// 返回0表面通信对方已经关闭连接
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 往sockfd上写入数据,返回实际写入数据长度

没有特殊要求就用0

MSG_OOB发送的数据仅有最后一字节会作为OOB数据被接收,且对正常数据的接收会被OOB数据截断,中间夹杂着OOB数据的数据需要多次recv才能读出。

UDP
1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int fags, const struct sockaddr* dest_addr, socklen_t addrlen);

这两个函数同样可以用于面向连接的数据读写,只需要将最后两个参数设置为NULL。

通用数据读写

可以用于TCP、UDP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr* msg, int flags);
struct msghdr
{
void* msg_name; // socket地址,TCP则为NULL
socklen_t msg_namelen; // socket地址的长度
struct iovec* msg_iov; // 分散的内存块,iovec数组
int msg_iovlen; // 分散内存块的数量,数组元素数
void* msg_control; // 指向辅助数据的起始位置,往往是cmsghdr
socklen_t msg_controllen; // 辅助数据的大小
int msg_flags; // 复制函数中的flags参数,并在调用过程中更新,所以调用函数前无需设置
};
struct iovec
{
void* iov_base; // 内存起始地址
size_t iov_len; // 这块内存的长度
};

数据存在分散的内存块中,需要分散读(scatter read),发送时一并发送,称为集中写(gather write)

带外标记

由于实际应用中程序不知道什么时候OOB数据到来,Linux提供了函数用于判断下一个被读取的数据是否是带外数据。

1
2
3
#include <sys/socket.h>
int sockatmark(int sockfd);
// 如果下一个被读取的数据是OOB,则返回1

地址信息函数

1
2
3
4
5
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr* address, socklen_t* address_len);
// 获取本地sockfd对应的socket address
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len);
// 获取远端与sockfd连接的socket address,与accept几乎一致

socket选项

socket文件描述符属性读取和修改

1
2
3
4
5
6
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int option_name, void* option_value,
socklen_t* restrict option_len);
int setsockopt(int sockfd, int level, int option_name, const void* option_value,
socklen_t option_len);
// level指定要操作的协议(IPv4、IPv6、TCP等),option_name指定选项

部分选项仅在listen、connect前设置有效:SO_DEBUG、SO_DONTROUTE、SO_KEEPALIVE、SO_LINGER、SO_OOBINLINE、SO_RCVBUF、SO_RCVLOWAT、SO_SNDBUF、SO_SNDLOWAT、TCP_MAXSEG、TCP_NODELAY。

SO_REUSEADDR可以使得sock即使处于TIME_WAIT状态,与之绑定的socket地址也可以立即被重用,也可以通过/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的socket,从而使得TCP连接根本就不进入TIME_WAIT状态。

SO_RCVBUF、SO_SNDBUF用来控制接收缓冲区和发送缓冲区,setsockopt时实际上是将缓冲区设为max(2*value, min_value)。可以通过/proc/sys/net/ipv4/tcp_rmem和/proc/sys/net/ipv4/tcp_wmem来强制接收缓冲区和发送缓冲区没有最小值限制。

SO_RCVLOWAT和SO_SNDLOWAT是低水位标记,当接收缓冲区的可读数据总数大于其低水位标记时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据,当发送缓冲区中的空闲空间大于低水位标记时,I/O复用系统调用将通知应用程序可以往对应的socket上写入数据。默认情况下,低水位标记都为1字节。

SO_LINGER用于控制close系统调用的行为。默认情况下,close将立即返回,TCP会把发送缓冲区中残留的数据发送给对方。SO_LINGER选项需要linger结构体。

1
2
3
4
5
6
7
8
9
10
11
12
#include <sys/socket.h>
struct linger
{
int l_onoff; // 0表示off,非0表示on
int l_linger;// 滞留时间
};
// 如果是off,则close为默认行为
// 如果是on,l_linger等于0,则close立即返回,发送缓冲区残留的数据被丢弃,同时给对方发送一个复位报文段,即一种异常终止连接的方法。
// 如果是on且l_linger非0,
// 如果是阻塞的socket,则会尝试在l_linger时间内发完所有残留数据并得到对方确认,
// 如果没有成功则返回-1并设置errno为EWOULDBLOCK
// 如果是非阻塞的socket,则close立即返回,可以根据返回值和errno判断残留数据是否发送完毕。

网络信息API

host
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <netdb.h>
struct hostent* gethostbyname(const char* name);
// 先在/etc/hosts中查找,再去访问DNS服务器
struct hostent* gethostbyaddr(const void* addr, size_t len, int type);
// type为地址族
struct hostent
{
char* h_name; // 主机名
char** h_aliases; // 主机别名列表
int h_addrtype; // 地址族
int h_length; // 地址长度
char** h_addr_list; // 按网络字节序列出的主机IP地址列表
};
service
1
2
3
4
5
6
7
8
9
10
11
12
#include <netdb.h>
struct servent* getservbyname(const char* name, const char* proto);
struct servent* getservbyport(int port, const char* proto);
// 通过读取/etc/services
// name指定服务名字,tcp表示流服务,udp表示数据报服务,NULL表示所有类型服务
struct servent
{
char* s_name;
char** s_aliases;
int s_port;
char* s_proto; // 服务类型,如tcp、udp
};

需要注意的是以上四个host和service的函数都是不可重入的。可重入版本是函数_r。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <netdb.h>
int getaddrinfo(const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result);
// 该函数可以用于同时获取IP地址(根据主机名)和端口号(根据服务名)结果存在result中,result指向一个链表
// hints用于实现对输出更精确的控制,可以被设置为NULL。可以设置ai_flags,ai_family, ai_socktype,ai_protocol,其他字段必须设为NULL
// 可重入性取决于内部实现
// 该函数会动态分配内存
struct addrinfo
{
int ai_flags;
int ai_family; // 地址族
int ai_socktype; // 服务类型,如SOCK_STREAM、SOCK_DGRAM
int ai_protocol; // 具体的网络协议,与socket的第三个参数类似,由于地址族和服务类型基本已经确定了网络协议,所以通常为0
socklen_t ai_addrlen;
char* ai_canonname; // 主机的别名
struct sockaddr* ai_addr;
struct addrinfo* ai_next;
};
void freeaddrinfo(struct addrinfo* res); // 用于释放空间

1
2
3
4
5
6
#include <netdb.h>
int getnameinfo(const struct sockaddr* sockaddr, socklen_t addrlen, char* host,
socklen_t hostlen, char* serv, socklen_t servlen, int flags);
// 用于同时获取主机名和服务名
// flags控制它的行为
// 可重入性取决于内部实现

Linux提供了将errno转换成易读字符串形式的函数

1
2
#include <netdb.h>
const char* gai_strerror(int error);

高级I/O函数

pipe

1
2
3
4
5
6
#include <unistd.h>
int pipe(int fd[2]);
// 将两个文件描述符用管道连接,fd[0]能从管道读出数据,fd[1]能向管道写入数据
// 默认情况下,这一对文件操作符都是阻塞的。
// 如果管道的写端文件描述符fd[1]的引用计数减少至0,则fd[0]的read会返回0,即读取到了EOF。
// 如果管道的读端文件描述符fd[0]的引用计数减少至0,则fd[1]的write会引发SIGPIPE信号

管道容量默认是65536字节,可以通过fcntl函数来修改。

可以通过socketpair方便地创建双向管道

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int fd[2]);
// domain只能使用AF_UNIX,因为仅能在本地使用双向管道

dup和dup2

当我们希望将标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接(比如CGI编程)时,可以用该函数。

1
2
3
4
5
6
#include <unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one, int file_descriptor_two);
// 创建一个新的与file_descriptor指向相同文件、管道或网络连接的文件描述符(属性不复制)
// dup返回的文件描述符总是取系统当前可用的最小文件描述符整数值
// dup2返回第一个不小于file_descriptor_two的整数值

可以通过close原本的文件(比如标准输入、输出),再dup要重定向到的文件,使得新的文件描述符的值恰好与close的相同从而达到重定向的作用。

分散度和集中写

1
2
3
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec* vector, int count);
ssize_t writev(int fd, const struct iovec* vector, int count);

sendfile

sendfile用于在两个文件描述符之间直接传递数据(完全在内核中操作,效率很高,零拷贝),通常用于将文件通过网络发送。

1
2
3
4
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
// in_fd必须是支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道
// out_fd必须是socket

mmap和munmap

mmap用于申请一段内存空间,可用于进程间通信的共享内存,也可以将文件直接映射到其中

1
2
3
4
#include <sys/mman.h>
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);
// start允许用户自行指定内存的起始地址,如果为NULL则系统分配,prot用于设置内存段的访问权限
int munmap(void* start, size_t length);

其中MAP_SHARED和MAP_PRIVATE互斥。

mmap失败返回MAP_FAILED((void*)-1)并设置errno

splice

用于在两个文件描述符之间移动数据,同样是零拷贝操作

1
2
3
4
5
6
#define _GNU_SOURCE
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out,
size_t len, unsigned int flags);
// 如果fd_in是管道,那么off_in必须为NULL
// fd_in和fd_out必须至少有一个是管道文件描述符,返回移动字节数量。失败时返回-1并设置errno

tee

用于在两个管道文件描述符之间复制数据,也是零拷贝,且不消耗数据,原文件描述符上的数据仍然可以用于后续的读操作

1
2
#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

fcntl

对文件描述符进行各种操作

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

常用cmd如下

在网络编程中,往往可以用来将文件描述符设置为非阻塞的

1
2
3
4
5
6
7
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; // 以便日后恢复该状态标志
}

此外,SIGIO、SIGURG信号必须与某个文件描述符通过fcntl关联后才可使用。

当被关联的文件描述符可读或可写时,系统将触发SIGIO信号,当被关联的文件描述符(必须是一个socket)上有带外数据可读时,系统将触发SIGURG信号。

Linux服务器程序规范

  • Linux服务器程序一般以后台进程形式运行。后台进程又称守护进程(daemon)。它没有控制终端,因而也不会意外接收到用户输入。守护进程的父进程通常是init进程(PID为1的进程)。

  • Linux服务器程序通常有一套日志系统,它至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器。大部分后台进程都在/var/log目录下拥有自己的日志目录。

  • Linux服务器程序一般以某个专门的非root身份运行。比如 mysqld、httpd、syslogd等后台进程,分别拥有自己的运行账户mysql、 apache和 syslog。

  • Linux服务器程序通常是可配置的。服务器程序通常能处理很多命令行选项,如果一次运行的选项太多,则可以用配置文件来管理。绝大多数服务器程序都有配置文件,并存放在/etc目录下

  • Linux服务器进程通常会在启动的时候生成一个PID文件并存入/var/run目录中,以记录该后台进程的PID。比如 syslogd 的PID文件是/var/run/syslogd.pid.

  • Linux服务器程序通常需要考虑系统资源和限制,以预测自身能承受多大负荷,比如进程可用文件描述符总数和内存总量等。

日志

Linux用一个守护进程(daemon)syslogd来处理系统日志,不过现在的Linux系统上使用的都是它的升级版rsyslogd。

rsyslogd可以接受用户进程和内核的日志。用户进程通过syslog函数生成系统日志,该函数将日志输出到一个AF_UNIX的socket的文件/dev/log中,rsyslogd则监听该文件以获取用户进程的输出。内核日志在老的Linux系统上是通过另一个守护进程rklogd来管理的,rsyslogd则是利用额外的模块实现了相同的功能。内核日志由printk等函数打印至内核的ring buffer中,ring buffer的内容则直接映射到/proc/kmsg文件中。rsyslogd通过读取该文件获得内核日志。

rsyslogd会对收到的日志进行分发。默认情况下,调试信息会保存至/var/log/debug,普通信息保存至/var/log/messages,内核消息保存至/var/log/kern.log。可以在/etc/rsyslog.conf文件进行配置(主配置文件,子配置文件通常为/etc/rsyslog.d/*.conf)。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <syslog.h>
void syslog(int priority, const char* message, ...);
// priority: 设施值 | 日志级别
// 设施值的默认值是LOG_USER
// 常见日志级别:
#define LOG_EMERG 0 // 系统不可用
#define LOG_ALERT 1 // 报警,需要立即采取动作
#define LOG_CRIT 2 // 非常严重的情况
#define LOG_ERR 3 // 错误
#define LOG_WARNING 4 // 警告
#define LOG_NOTICE 5 // 通知
#define LOG_INFO 6 // 信息
#define LOG_DEBUG 7 // 调试

为了修改日志的格式,可以使用openlog函数

1
2
3
4
5
6
7
8
9
10
#include <syslog.h>
void openlog(const char* ident, int logopt, int facility);
// 并不是用于打开日志,而是为了改变syslog的输出格式,实际上是打开一个文件描述符与syslog进行通信
// ident字符串会被添加到之后syslog的每条日志消息的日期和时间之后,往往用于标识程序
// logopt有
#define LOG_PID 0x01 // 在日志消息中包含PID
#define LOG_CONS 0x02 // 如果消息不能记录到日志文件,则打印至终端
#define LOG_ODELAY 0x04 // 延迟openlog直到第一次调用syslog
#define LOG_NDELAY 0x08 // 不延迟openlog
// facility用于修改syslog的默认设施值

为过滤日志

1
2
3
4
#include <syslog.h>
int setlogmask(int maskpri);
// maskpri为日志掩码,日志级别大于日志掩码的会被忽略
// 返回修改之前的日志掩码
1
2
#include <syslog.h>
void closelog(); // 关闭打开的用于与syslog通信的文件描述符

用户

进程拥有两个用户ID: UID、EUID。用户运行某程序的代码时拥有该程序的EUID权限。同样组也有类似的EGID。EUID为root的进程称为特权进程。

1
2
3
4
5
6
7
8
9
10
#include <sys/types.h>
#include <unistd.h>
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 setegid(gid_t gid);

进程间关系

Linux下每个进程都隶属于一个进程组,因此它们除了PID外还有PGID。

1
2
#include <unistd.h>
pid_t getpgid(pid_t pid);

每个进程组都有一个首领进程,其PGID和PID相同。进程组将一直存在,直到其中所有进程都退出,或者加入到其他进程组。

1
2
3
4
5
6
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
// 设置目标pid进程所属进程组
// 如果pid == pgid则该pid进程被设置为首领进程
// 如果pid为0则设置当前进程的PGID为pgid
// 如果pgid为0则使用pid作为目标PGID

一个进程只能设置自己或者其子进程的PGID,并且当子进程调用exec系列函数后不能再在父进程中对它设置PGID。

非首领进程可以创建会话。

1
2
3
4
5
6
#include <unistd.h>
pid_t setsid(void);
// 调用进程成为会话的首领,此时该进程是新会话的唯一成员
// 新建一个进程组,其PGID就是调用进程的PID,即调用进程成为该组的首领
// 调用进程将甩开终端(如果有的话)
pid_t getsid(pid_t pid); // 返回PGID

系统资源限制

1
2
3
4
5
6
7
8
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit* rlim);
int setrlimit(int resource, const struct rlimit* rlim);
struct rlimit
{
rlim_t rlim_cur; // 软限制,是建议性的、最好不要超越的,超越可能系统会向进程发送信号以终止其运行
rlim_t rlim_max; // 硬限制一般是软限制的上限
};

普通程序可以减小硬限制,只有root身份运行的程序才能增加硬限制。

可以使用ulimit命令修改当前shell环境下的资源限制,这种修改对该shell启动的所有后续程序有效。也可以通过修改配置文件来修改,这种修改永久生效。

改变工作目录和根目录

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>
char* getcwd(char* buf, size_t size);
// 返回绝对路径,如果大于size,则返回NULL并设置errno为ERANGE
// 如果buf为NULL且size非0则会动态分配内存
// 成功时返回指针,失败则返回NULL并设置errno
int chdir(const char* path);
int chroot(const char*path);
// 改变进程根目录
// 不改变进程的当前工作目录,所以在chroot之后还需要chdir("/")切换至新的根目录
// 在调用chroot之后,进程原先打开的文件描述符依然生效
// 只有特权进程才能改变根目录

服务器程序后台化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 内部实现逻辑
bool daemonize()
{
// 创建子进程,关闭父进程,这样可以使程序在后台运行
pid_t pid = fork();
if (pid < 0) // fork fail
{
return false;
}
else if (pid > 0) // 父进程
{
exit(0);
}

// 设置文件权限掩码,当进程创建新文件时文件的权限将是mode & 0777
umask(0);

//创建新的会话,设置本进程为进程组的首领
pid_t sid = setsid();
if (sid < 0) return false;

// 切换工作目录
if (chdir("/") < 0) return false;

// 关闭标准输入、输出、错误输出设备
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);

// 关闭其他已经打开的文件描述符,略

// 将标准输入、输出、错误输出都重定向到/dev/null文件
open("/dev/null", O_RDONLY); // 因为此时open返回的fd是0,恰好是标准输入
open("/dev/null", O_RDWR); // fd是1
open("/dev/null", O_RWWR); // fd是2
}

实际上,Linux提供了完成同样功能的库函数

1
2
3
4
#included <unistd.h>
int daemon(int nochdir, int noclose);
// 如果nochdir为0则工作目录被改为"/",否则继续使用当前工作目录
// 如果noclose为0,则标准输入、输出、错误输出都被重定向到/dev/null,否则依然使用原来的设备

高性能服务器程序框架

I/O模型

socket创建时默认阻塞,可以通过socket系统调用的第二个参数传递SOCK_NONBLOCK或通过fcntl的F_SETFL设置非阻塞。

socket的基本API中,可能被阻塞的系统调用包括accept、send、recv、connect。

针对非阻塞的I/O执行的系统调用总是立即返回,如果事件没有立即发生,则这些系统调用返回-1.对于非阻塞的accept、send、recv,事件未发生时errno通常被设置成EAGAIN或EWOULDBLOCK,对于connect则是EINPROGRESS。

非阻塞I/O通常要与其他I/O通知机制一起使用,比如I/O复用和SIGIO信号。

I/O复用是最常用的I/O通知机制,应用程序通过I/O服用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序,如select、poll、epoll_wait。I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们能同时监听多个I/O事件。

SIGIO信号也可以用来报告I/O事件。可以为一个文件描述符指定宿主进程,当该文件描述符上有事件发生时,宿主进程将捕获到SIGIO信号,SIGIO信号的信号处理函数将被触发。

异步I/O的读写操作总是立即返回,而不论I/O是否阻塞,I/O读写由内核接管,内核通知I/O完成事件。

两种高效的事件处理模式

Reactor

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

使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程是:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

Proactor

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

使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程是:

  1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)
  2. 主线程继续处理其他逻辑
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)
  5. 主线程继续处理其他逻辑
  6. 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

用同步I/O模拟Proactor

以epoll_wait为例

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核时事件中注册socket上的写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 当socket可写时,epoll_wait通知主线程,主线程往socket上写入服务器处理客户请求的结果

两种高效的并发模式

半同步/半异步(half-sync/half-async)模式

同步线程用于处理客户逻辑,异步线程用于处理I/O事件。

一种变体成为半同步/半反应堆(half-sync/half-reactive)模式

领导者/追随者(Leader/Followers)模式

多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式

在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件,而其他线程都是追随者,他们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件。

包含组件:HandleSet、ThreadSet、EventHandler、ConcreteEvetHandler

HandleSet

  • Handle用于表示I/O资源
  • wait_for_event监听句柄上的I/O事件,将就绪事件通知给领导者线程
  • 领导者线程调用绑定到Handle上的事件处理器处理事件(绑定由register_handle实现)

ThreadSet

  • 线程集中的线程必定处于三种状态之一
    • Leader: 当前处于领导者身份,负责等待句柄集上的I/O事件
    • Processing: 正在处理事件。领导者检测到I/O事件后可以转移到Processing状态进行处理,并调用promote_new_leader推选新的领导,也可以指定其他追随者来处理事件(Event Handoff),此时领导者的身份不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它成为新的领导者,否则它就直接转变为追随者
    • Follower: 当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务

ConcreteEventHandler

  • 是事件处理器的派生类,必须重新实现基类的handle_event方法

有限状态机

内存池、进程池、线程池和连接池

I/O复用

select

在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds,
struct timeval* timeout);
// nfds: 被监听的文件描述符的总数,通常被设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的
// readfds、writefds、exceptfds分别指向可读、可写、异常等事件对应的文件描述符集合,select返回时内核会通过修改它们来通知应用程序哪些文件描述符已经就绪
// timeout用来设置select函数的超时时间,内核会修改它来告诉应用程序select等待了多久。如果是NULL则select会一直阻塞,直到某个文件描述符就绪
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微妙
};
// 成功时返回就绪文件描述符总数
// 如果select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// fd_set,本质是一个bitmap
#include <typesizes.h>
#define __FD_SETSIZE 1024

#include <sys/select.h>
#define FD_SETSIZE __FD_SETSIZE
typedef long int __fd_mask;
#undef __NFDBITS
#define __NFDBITS (8 * (int) sizeof(__fd_mask))
typedef struct
{
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
#define __FDS_BITS(set) ((set)->fds_bits)
#else
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
#define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;
// fd_set仅包含一个整型数组,该数组的每个元素的每一位标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定
// 在执行select前,1表示监听的文件描述符,在执行select后,1表示就绪的文件描述符
1
2
3
4
5
6
7
// Linux提供了一系列宏来访问fd_set中的位
#include <sys/select.h>
FD_ZERO(fd_set* fdset); // 清除fdset的所有位
FD_SET(int fd, fd_set* fdset); // 设置fdset的位fd,即监听fd
FD_CLR(int fd, fd_set* fdset); // 清除fdset的位fd
int FD_ISSET(int fd, fd_set* fdset); // 测试fdset的位fd是否被设置,即是否就绪
// 由于select会修改fd_set,所以每次调用select前都应重新FD_SET

网络编程中,socket可读就绪:

  • socket内核接收缓存区中的字节数大于等于其低水位标记SO_RCVLOWAT
  • socket通信对方关闭连接
  • 监听socket上有新的连接请求
  • socket上有未处理的错误(此时可以使用getsockopt来读取和清除该错误)

socket可写就绪:

  • socket内核发送缓冲区中的可用字节数大于等于其低水位标记SO_SNDLOWAT
  • socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号
  • socket使用非阻塞connect连接成功或失败(超时)之后
  • socket上有未处理的错误(此时可以使用getsockopt来读取和清除该错误)

异常就绪:

  • socket上接收到带外数据

poll

与select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。

1
2
3
4
5
6
7
8
9
10
11
#include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
// fds是一个数组,nfds指定数组元素个数
struct pollfd
{
int fd;
short events; // 注册的事件,一系列事件的按位或
short revents; // 实际发生的事件,由内核填充
};
typedef unsigned long int nfds_t;
// timeout指定超时事件,单位是毫秒,timeout为-1时poll将阻塞直到某个事件发生

POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND由XOPEN规范定义,它们实际上是将POLLIN事件和POLLOUT事件分得更细致,以区别对待普通数据和优先数据,但Linux并不完全支持它们。

使用POLLRDHUP事件时需要在代码最开始处定义_GNU_SOURCE。

epoll系列系统调用

epoll是Linux特有的I/O复用函数,使用一组函数完成任务。

epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。

epoll需要使用一个额外的文件描述符来标识内核中的事件表。

1
2
3
4
#include <sys/epoll.h>
int epoll_create(int size);
// size不起作用,只是给内核一个提示,告诉它事件表需要多大
// 返回值是其他所有epoll系统调用的第一个参数epfd

通过epoll_ctl对事件表进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
// op有3种: EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL
struct epoll_event
{
__uint32_t events; // epoll事件
epoll_data_t data; // 用户数据
};
// epoll支持的事件类型和poll基本相同,只需要在宏前加E就可以了
// epoll还有两个额外的事件类型EPOLLET和EPOLLONESHOT,它们对应epoll的高效运作非常关键
typedef union epoll_data
{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
// ptr可用来指向与fd相关的用户数据,但由于是union,使用ptr时一般将fd放到ptr指向的空间中
1
2
3
4
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
// events是该函数的输出,所有就绪的事件都会被复制到这个数组中
// timeout为-1将阻塞

epoll对文件描述符有两种操作模式:LT(电平触发)模式和ET(边沿触发)模式。LT是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。

对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理此事件。当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通报该事件,直到事件被处理。

对于采用ET模式的,通知后,应用程序必须立即处理该事件,后续的epoll_wait调用将不再向应用程序通知该事件。

从实现上来看,ET和LT的区别在于事件就绪的判断。

对于LT

  • 读操作:缓冲区不为空
  • 写操作:缓冲区不为满

对于ET

  • 读操作
    • 缓冲区内容变多
    • 缓冲区不为空且EPOLLIN事件 EPOLL_CTL_MOD
  • 读操作
    • 缓冲区内容减少
    • 缓冲区不为满且EPOLLOUT事件 EPOLL_CTL_MOD

即使使用ET模式,一个socket上的某个事件还是可能被触发多次。一个线程在读取完某个socket上的数据后开始处理这些数据,而数据处理过程中该socket上又有新的数据可读(EPOLLIN再次触发),此时另一个线程又被唤醒来读取这些数据。于是就出现了两个线程同时操作一个socket的局面。为避免该问题,可以使用EPOLLONESHOT。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或异常事件,且只触发一次,直到再次使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件(EPOLL_CTL_MOD)。

三种I/O复用函数比较

epoll直接将事件结果复制到数组中,避免了遍历,因而更适用连接数量多而活动连接较少的情况。

select一般有最大值限制,虽然可以修改,但容易产生不可预期的错误。

I/O复用的高级应用

非阻塞的socket进行connect,如果返回时连接还没有建立,将设置errno为EINPROGRESS。在这种情况下,我们应监听这个连接暂时失败的socket上的可写事件。当select、poll等函数返回后,利用getsockopt来读取错误码并清除该socket上的错误。如果错误码是0则连接成功建立,否则失败。但需要注意的是,这方法存在移植性问题。首先,非阻塞的socket可能导致connect始终失败。其次,select对处于EINPROGRESS状态下的socket可能不起作用。最后,对于出错的socket,getsockopt在不同系统上返回值不一样,Linux返回-1,伯克利的UNIX返回0。

同一个端口可以创建多个socket用于处理不同服务如TCP、UDP。

超级服务xinetd

Linux因特网服务inetd是超级服务,同时管理着多个子服务,即监听多个端口。现在Linux系统上使用的inetd服务程序通常是其升级版xinetd。它新增了一些控制选项,提高了安全性。

主配置文件/etc/xinetd.conf,/etc/xinetd.d子配置文件夹

它的子服务telnet的配置文件/etc/xinetd.d/telnet典型内容如下

对于其他更多配置,可以参考man。

信号

信号的产生

  1. 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如Ctrl+c通常会给进程发送一个中断信号
  2. 系统异常。比如浮点异常和非法内存访问
  3. 系统状态变化。比如alarm定时器到期将引起SIGALRM信号
  4. 运行Kill命令或调用kill函数

服务器程序必须处理(或至少忽略)一些常见的信号,以免异常终止。


linux使用kill函数发送信号

1
2
3
4
5
#include <sys/types.h>
#included <signal.h>
int kill(pid_t pid, int sig);
// 如果sig为0则不发生任何信号,但依然为检测目标进程或进程组是否存在
// 但这种检测方式是不可靠的,一方面这种检测方式不是原子操作,另一方面进程PID的回绕可能导致被检测的PID不是我们期望的进程的PID(回绕是因为linux系统是按顺序循环分配PID的)

用户可以通过给信号绑定信号处理函数来实现对信号的响应。信号处理函数应该是可重入的,并且由于我们希望同一个信号在多次触发时能够不被屏蔽,信号处理函数应该能够迅速执行完,所以信号处理函数往往只是作为一个中介,将信号值通过管道传递给主循环。

1
2
3
4
5
6
7
8
9
10
11
12
#include <signal.h>
typedef void (*__sighandler_t)(int);// 信号处理函数的类型
// 由于往往是多个信号都绑定同一个信号处理函数,用一个int信号来说明是哪个信号是必要的

// 自定义信号处理函数
// 需要保存恢复errno
void handler(int sig)
{
int origin_errno = errno;
// handling process
errno = origin_errno;
}

除了自定义信号处理函数,Linux还提供了两个特殊的标识来进行其他处理

1
2
3
4
5
6
#include <bits/signum.h>
#define SIG_DFL ((__sighandler_t) 0)
// 使用信号的默认处理方式
// 结束进程(Term)、忽略信号(Ign)、结束进程并生成核心转储文件(Core)、暂停进程(Stop)、继续进程(Cont)
#define SIG_IGN ((__sighandler_t) 1)
// 忽略目标信号

Linux将可用信号(标准信号+POSIX实时信号)都定义在bits/signum.h中

如果程序在执行处于阻塞状态的系统调用时收到信号,并且该信号设置了信号处理函数,则默认情况下系统调用将被中断并且errno被设置为EINTR。可以使用sigaction函数为信号设置SA_RESTART标志以自动重启被该信号中断的系统调用。

对默认行为是暂停进程的信号(比如SIGSTOP、SIGTTIN),如果没有设置信号处理函数,它们也是可以中断某些系统调用的(比如connect、epoll_wait)。这是Linux独有的。

绑定信号处理函数使用signal系统调用

1
2
3
4
#include <signal.h>
_sighandler_t signal(int sig, _sighandler_t _handler);
// 返回之前这个sig注册的_handler,也即上一次调用这个sig时传入的_handler,第一次为sig调用是则为SIG_DFL
// 错误则返回SIG_ERR,并设置errno

但这个系统调用基本deprecated了,更常用的是sigaction。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <signal.h>
int sigaction(int sig, const struct sigaction* act, struct sigaction* oact);
// act指定新的信号处理方式,oact则输出之前的信号处理方式
// sig不能是SIGKILL和SIGSTOP
struct sigaction
{
#ifdef __USE_POSIX199309
union
{
_sighandler_t sa_handler;
void (*sa_sigaction) (int, siginfo_t*, void*);
}_sigaction_handler;
#define sa_handler __sigaction_handler.sa_handler
#define sa_sigaction __sigaction_handler.sa_sigaction
#else
_sighandler_t sa_handler;
#endif
_sigset_t sa_mask; // 实际上是一个bitmap,用于设置进程原有信号掩码基础上额外的信号掩码,以指定哪些信号在执行handler的时候应被挂起(没被mask的信号将会打断该信号处理过程)
int sa_flags; // 用于设置程序收到信号时的行为
void (*sa_restorer)(void); // deprecated,最好不要使用
}

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <bits/sigset.h>
#define _SIGSET_NWORDS (1024 / (8 * sizeof(unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
}__sigset_t;

#include <signal.h>
int sigemptyset(sigset_t* _set);
int sigfillset(sigset_t* _set);
int sigaddset(sigset_t* _set, int _signo);
int sigdelset(sigset_t* _set, int _signo);
int sigismember(_const sigset_t* _set, int _signo);

如果只是想要设置/获得进程掩码,可以使用

1
2
3
#include <signal.h>
int sigprocmask(int _how, _const sigset_t* _set, sigset_t* _oset);
// _set为NULL时_oset依然能获取进程当前的信号掩码

设置信号掩码后,被屏蔽的信号将不能被进程接收,但该信号会被暂时挂起。此时如果取消屏蔽,它依然能被进程接收到。

1
2
#include <signal.h>
int sigpending(sigset_t* set);// 获取进程当前被挂起的信号集

显然,即使该信号被多次触发,也只能被检测到一次。

网络编程相关信号

SIGHUP

当挂起进程的控制终端时,SIGHUP信号将被触发。对于没有控制终端的网络后台程序而言,这个信号往往是强制要求服务器重读配置文件。

SIGPIPE

默认情况下,往一个读端关闭的管道或者socket连接中写数据将引发SIGPIPE,程序接收到SIGPIPE信号的默认行为是结束进程,所以往往需要在代码中捕获并处理该信号,或者至少忽略它。引起SIGPIPE信号的写操作将设置errno为EPIPE。

我们可以使用send函数的MSG_NOSIGNAL标志来禁止写操作触发SIGPIPE信号。在这种情况下应使用send函数反馈的errno来判断管道或socket连接的读端是否已经关闭。也可以用I/O复用系统调用来检测。管道的读端关闭时,写端文件描述符上的POLLHUP事件将被触发,socket连接被对方关闭时,socket上的POLLRDHUP事件将被触发。

SIGURG

收到带外数据

定时器

Linux提供了三种定时方法

  • socket选项SO_RCVTIMEO和SO_SNDTIMEO
  • SIGALRM信号
  • I/O复用系统调用的超时参数

socket选项SO_RCVTIMEO和SO_SNDTIMEO

SIGALRM信号

由alarm和setitimer函数设置的实时闹钟一旦超时,将触发SIGALRM信号。

如果不需要非常精确,可以使用alarm

1
2
3
4
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 取消之前设置的闹钟并设置新的闹钟,返回值为之前闹钟剩下的时间(之前没有设置过则为0)
// 如果sedonds为0,则之前设置的闹钟会取消,并将剩下的时间返回

getitimer/setitimer通过which参数提供了更精确的时间选项。

1
2
3
#include <sys/time.h>
int getitimer(int which, struct itimerval* value);
int setitimer(int which, const struct itimerval* restrict value, struct itimerval* restrict ovalue);
which description
ITIMER_REAL 以系统真实的时间来计算,它送出SIGALRM信号
ITIMER_VIRTUAL 以该进程在用户态下花费的时间来计算,它送出SIGVTALRM信号
ITIMER_PROF 以该进程在用户态下和内核态下花费的时间来计算,它送出SIGPROF信号

I/O复用系统调用的超时参数

如果epoll_wait的返回值等于0,则过去了timeout时间,否则经过了(end - start) * 1000ms。

高性能定时器

时间轮(TimingWheel)

指针指向当前所在的时间槽slot,每个tick(slot interval, si)移动到下一个槽。每个槽都是一个定时器链表。指针每指向一个槽,都要遍历该槽里的所有定时器。指针花费N*si走完一个round,不是所有定时器都在一个round内的,所以每个定时器还有个round变量记录还剩几个round才到时间,如果指针指向定时器所在的槽且定时器的round为0则表明时间到。

若当前指针指向槽cs,要添加一个定时时间为ti的定时器,则该定时器应被插入槽ts的链表中,有 ts = ( cs + ( ti / si ) ) % N 如果想要提高定时精度,需要si够小;要提高执行效率,需要N购大。

可以实现多层级的时间轮控制不同粒度的定时。

时间堆

每次都以所有定时器中超时值最小的定时器的超时值发出SIGALRM,一旦SIGALRM,则最小的定时器必然到期。我们可以处理该定时器然后找出下一个超时时间最小的定时器并设置。最小堆非常适合于解决该问题。

高性能I/O框架库Libevent

ACE、ASIO、Libevent都是开源的优秀的I/O框架库,其中Libevent相对轻量级。

基于Reactor模式实现的I/O框架库包含组件:句柄、事件多路分发器(EventDemultiplexer)、事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)。

事件源:I/O事件、信号和定时事件

一个事件源通常和一个句柄绑定在一起。当内核检测到事件发生时,它将通过句柄来通知应用程序这一事件。Linux的I/O事件的句柄是文件描述符,信号事件的句柄是信号值。

I/O框架库一般将系统支持的各种I/O复用系统调用封装成统一的接口,称为事件多路分发器。它的demultiplex方法是等待事件的核心函数。

当事件多路分发器检测到有事件发生时,通过句柄通知应用程序。事件处理器需与句柄绑定。

事件处理器一般提供一个get_handle方法,它返回与该事件处理器关联的句柄。

Reactor提供几个主要方法

  • handler_events: 执行事件循环。重复如下过程:等待事件,然后依次处理所有就绪事件对应的事件处理器
  • register_handler: 调用事件多路分发器的register_event方法来往事件多路分发器中注册一个事件
  • remove_handler: 调用事件多路分发器的remove_event方法来删除事件多路分发器中的一个事件。

Libevent跨平台支持、统一事件源、线程安全,基于Reactor模式实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "event2/event.h"
#define evsignal_new(b, x, cb, arg) \
event_new((b), (x), EV_SIGNAL|EV_PERSIST, (cb), (arg))
#define evtimer_new(b, cb, arg) event_new((b), -1, 0, (cb), (arg))
struct event* event_new(struct event_base* base, evutil_socket_t fd, short events,
void (*cb)(evutil_socket_t, short, void*), void* arg);
// fd是句柄
// events如下
#define EV_TIMEOUT 0x01 // 定时
#define EV_READ 0x02 // 可读
#define EV_WRITE 0x04 // 可写
#define EV_SIGNAL 0x08 // 信号
#define EV_PERSIST 0x10 // 永久。事件被触发后,自动重新对这个event调用event_add
#define EV_ET 0x20 // 边沿触发,需要I/O复用系统调用支持,比如epoll
// cb是回调函数,相当于事件处理器的handle_event,arg是传给它的参数
// 返回事件处理器对象

事件由事件多路分发器管理,事件处理器则由事件队列管理。


总体流程

  1. 调用event_init创建event_base对象,相当于Reactor实例
  2. 用event_new(evsignal_new、evtimer_new)创建事件处理器
  3. 用event_add将事件处理器添加到注册事件队列中,并将该事件处理器对应的事件添加到事件多路分发器中,相当于register_handler
  4. 调用event_base_dispatch执行事件循环
  5. 使用*_free来释放系统资源

struct event有许多指针,这些指针将多个struct event串成了多个尾队列。ev_next形成注册事件队列;ev_active_next形成活动事件队列(活动事件队列不止一个,不同优先级的事件处理器被激活后插入不同的活动事件队列。在事件循环中,Reactor将按优先级从高到低遍历所有活动事件队列);联合体ev_timeout_pos在通用定时器(即简单链表实现的)中用ev_next_with_common_timeout形成通用定时器队列,在时间堆中用min_heap_idx指示位置。一个定时器是否要采用通用定时器取决于其超时值大小;联合体_ev用ev_ioev_io_next形成具有相同文件描述符的I/O事件队列,用ev_signal.ev_signal_next形成信号事件队列,ev_signal.ev_ncalls指定信号事件发生时Reactor需要执行多少次该事件对应的事件处理器的回调函数(在启用ev_flags的EV情况下),ev_pncalls要么NULL,要么指向它。

ev_res记录当前激活事件的类型。

ev_flags如下

1
2
3
4
5
6
7
#define EVLIST_TIMEOUT 	0x01 			// 事件处理器从属于通用定时器队列或时间堆
#define EVLIST_INSERTED 0x02 // 事件处理器从属于事件队列
#define EVLIST_SIGNAL 0x04 // 没有使用
#define EVLIST_ACTIVE 0x08 // 事件处理器从属于活动事件队列
#define EVLIST_INTERNAL 0x10 // 内部使用
#define EVLIST_INIT 0x80 // 事件处理器已经被初始化
#define EVLIST_ALL (0xf000 | 0x9f) // 定义所有标志

ev_pri为优先级,越小优先级越高

ev_closure定义执行回调函数时的行为

1
2
3
#define EV_CLOSURE_NONE 	0 	// 默认行为
#define EV_CLOSURE_SIGNAL 1 // 执行信号事件处理器的回调函数时,调用ev_ncalls次回调函数
#define EV_CLOSURE_PERSIST 2 // 执行完回调函数后,再次将事件处理器加入注册事件队列中

ev_timeout仅对定时器有效

多进程编程

fork

1
2
3
4
5
6
7
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
// 在父进程中返回的是子进程的PID,在子进程中返回0
// 复制当前进程,在内核进程表中创建一个新的进程表项,新的进程表项有很多属性和原进程相同,如堆指针、栈指针和标志寄存器的值,但也有很多属性被赋予了新的值,比如PID,信号位图被清除
// 子进程代码和父进程完全相同,同时还会复制父进程的数据(堆数据、栈数据、静态数据),数据的复制采用的是copy on write
// 父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加一。父进程的用户根目录、当前工作目录等的引用计数也会加一

exec系列系统调用

替换当前进程映像

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>
extern char** environ;
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);
// path指定可执行文件的完整路径,file指定文件名,该文件的具体位置在PATH中搜寻
// arg、argv被传递给新程序的main
// envp设置新程序的环境变量,如果未设置,新程序会使用由全局变量environ指定的环境变量
// 一般情况下不返回,因为如果没出差则原程序exec调用之后的代码都不会执行,已经被完全替换。出错时返回-1并设置errno
// 不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性

处理僵尸进程

父进程一般需要跟踪子进程的退出状态。当子进程结束运行后,内核不会立即释放该进程的进程表表项。在子进程结束运行之后,父进程读取其状态之前,称该子进程处于僵尸态。父进程先于子进程结束,子进程的PPID被设置为1,即init进程,init进程接管了该子进程,并等待其结束,该状态子进程也称为僵尸态。停留在僵尸态的子进程依然占据着内核资源。

1
2
3
4
5
6
7
8
#include <sys/types.h>
#include <sys/wait.h>
// 等待子进程的结束,并获取子进程的返回信息
pid_t wait(int* stat_loc);
// 阻塞,等待任一个子进程结束,返回结束运行的子进程的PID并将子进程的退出状态信息存储于stat_loc参数指向的内存中
pid_t waitpid(pid_t pid, int* stat_loc, int options);
// 只等待由pid指定的子进程,如果pid==-1则等待任一个子进程结束
// 如果options为WNOHANG则非阻塞,调用时目标子进程还没有结束或意外结束则返回0,否则返回该子进程的PID。
退出状态信息

在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。子进程在结束时会给父进程发送SIGCHLD信号,父进程可以通过该信号得知子进程是否结束,并在知道结束后调用waitpid以彻底结束它。

进程间通信

用管道在父子间通信

fork后原先打开的管道文件描述符fd[0]fd[1]依然处于打开状态,由于一对管道只能保证一个方向的数据传输,所以父进程和子进程必须有一个关闭fd[0],另一个关闭fd[1]

无关联进程之间的通信

FIFO管道

有一种特殊的管道称为FIFO(先进先出),也叫命名管道。它能用于无关联进程之间的通信,但网络编程中使用不多。

信号量(Semaphore)

Linux/Unix常用P(传递)、V(释放)来代替信号量中的wait、signal。

对于信号量SV

  • P(SV): 如果SV的值大于0,则减1,继续执行;如果为0,则挂起进程的执行
  • V(SV): 如果有其他进程因为等待SV而挂起,就唤醒一个被挂起的进程;如果没有,则将SV加一
semget系统调用
1
2
3
4
5
6
#include <sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
// key用来唯一标识一个全局的信号量集,要通过信号量通信的进程需要使用相同的key来创建/获取该信号量
// sem_flags低9位表示权限,和open的mode参数相同。IPC_CREAT是一个高位标志,表示创建新的信号量集,但不能保证信号量是还不存在的。使用IPC_CREATE | IPC_EXCL来确保是一组新的唯一的信号量集,它会使得如果产生的信号量已经存在,semget会返回-1并设置errno为EEXIST。这与open的O_CREAT|O_EXCL类似。
// 将key设为IPC_PRIVATE(注意不是sem_flags)时,sem_flags的低9位以外的值全被忽略,生成一个新的信号量集(更准确的说法是IPC_NEW,但由于历史遗留问题)
// 成功时返回信号量集的标识符

semget函数在创建信号量集时会对与之关联的内核数据结构体semid_ds执行创建并初始化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <sys/sem.h>
// 描述IPC对象(信号量、共享内存、消息队列)的权限
struct ipc_perm
{
key_t key;
uid_t uid; // 被初始化为调用进程的有效用户/组ID
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode; // 初始化时低9为设置成sem_flags的低9位
// 省略其他
};
struct semid_ds
{
struct ipc_perm sem_perm;
unsigned long int sem_nsems; // 信号量数目,初始化成num_sems
time_t sem_otime; // 最后一次调用semop的时间,初始化为0
time_t sem_ctime; // 最后一次调用semctl的时间,初始化为当前的系统时间
// 省略其他
};
semop系统调用

与每个信号量关联的一些重要的内核变量

1
2
3
4
unsigned short semval;		// 信号量的值
unsigned short semzcnt; // 等待信号量值变为0的进程数量
unsigned short semncnt; // 等待信号量值增加的进程数量
pid_t sempid; // 最后一次指向semop操作的进程ID

semop对信号量的操作实际上就是对这些内核变量的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <sys/sem.h>
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);
struct sembuf
{
unsigned short int sem_num; // 信号集中信号量的编号
short int sem_op; // 操作类型,正数、0、负数
short int sem_flg; // 可选值:IPC_NOWAIT和SEM_UNDO,SEM_UNDO表示当进程退出时,UNDO当前在进行的semop
};
// sem_ops和num_sem_ops组成一个数组,semop对数组元素依次操作,该过程是原子操作,失败的时候所有操作都不被执行。
// 如果sem_op大于0,则semop将被操作的信号量的值semval增加sem_op。该操作要求调用进程在对被操作信号量集上拥有写权限。此时若设置了SEM_UNDO,则系统将更新进程的semadj变量
// 如果sem_op等于0,表示这是一个“等待0”操作。该操作要求调用进程对呗操作信号量拥有读权限。如果此时信号量的值是0,则调用立即成功返回。如果信号量的值不是0,则semop操作失败或者阻塞进程以等待信号量变为0(根据IPC_NOWAIT是否设置,失败会设置errno为EAGAIN)。如果未指定IPC_NOWAIT,则信号量semzcnt加1,进程被投入睡眠,知道下列3个条件之一发生:
// 信号量的值变为0,此时系统将该信号量semzcnt减1.
// 被操作的信号量所在的信号量集被进程移除,此时,semop调用失败返回,errno被设置为EIDRM。
// 调用被信号中断,此时,semop调用失败返回,errno被设置EINTR,同时系统将信号量的semzcnt减1。
// 如果sem_op小于0,则表示对信号量进行减操作。即期望获得信号量,该操作要求调用进程对被操作信号量集拥有写权限。如果信号的值semval大于或者等于sem_op的绝对值,则semop操作成功,调用进程立即获得信号量,并且系统将该信号量的semval减去sem_op的绝对值。此时如果设置了SEM_UNDO标志,则系统将更新进程的semadj变量。如果信号量的值semval小于sem_op的绝对值,则semop失败返回或者阻塞等待信号量可用。在这种情况下,IPC_NOWAIT的标志被指定时,semop立即返回一个错误,并设置errno为EAGAIN。如果为指定IPC_NOWAIT标志,则信号量的semncnt加1,进程被投入睡眠知道下面三个条件满足:
// 信号量的值semval大于等于sem_op的绝对值,此时信号量的semncnt减1,并将semval减去sem_op的绝对值。
// 被操作信号量所在的信号量集被进程删除,调用返回失败,errno被设置为EIDRM;
// 调用被信号中断,此时semop调用失败返回,errno被设置为EINTR,同时系统将semncnt值减1。
semctl系统调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <sys/sem.h>
int semctl(int sem_id, int sem_number, int command, ...);
// sem_id是信号集,sem_number是被操作信号量在信号集中的下标
// 第四个参数类型由用户自己定义,但sys/sem.h给出了推荐格式
union semun
{
int val; // 用于SETVAL命令
struct semid_ds* buf; // 用于IPC_STAT和IPC_SET命令
unsigned short* array; // 用于GETALL和SETALL命令
struct seminfo* __buf; // 用于IPC_INFO命令
};
struct seminfo
{
int semmap; // Linux内核没有使用
int semmni; // 系统最多可以拥有的信号量集数目
int semmns; // 系统最多可以拥有的信号量数目
int semmnu; // Linux内核没有使用
int semmsl; // 一个信号量集最多允许包含的信号量数目
int semopm; // semop一次能最多执行的sem_op操作数目
int semume; // Linux内核没有使用
int semusz; // sem_undo结构体的大小
int semvmx; // 最大允许的信号量值
int semaem; // 最多允许的UNDO次数(带SEM_UNDO标志的semop操作的次数)
};
semctl的command参数

除GETNCNT、GETPID、GETVAL、GETZCNT、SETVAL的其他操作都是针对整个信号量集的,此时semctl的参数sem_num被忽略

共享内存

共享内存是效率最高的IPC,但是往往需要配合一些其他手段来防止竞态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg); // 创建一段新的共享内存,或者获取一段已经存在的共享内存
// 如果创建新的共享内存,size必须被指定。如果是获取已经存在的共享内存,则size可以设置为0
// shmflg除了支持IPC_CREATE、IPC_EXCL外还支持SHM_HUGETLB(类似mmap的MAP_HUGETLB,系统将使用大页面来为共享内存分配空间)、SHM_NORESERVE(类似mmap的MAP_NORESERVE,不为共享内存保留交换分区(swap空间),这样,当物理内存不足时,对该共享内存执行写操作将触发SIGSEGV信号)
// 返回共享内存标识符
// 创建共享内存时共享内存的所有字节都被初始化为0,与之关联的内核数据结构shmid_ds被创建和初始化
struct shmid_ds
{
struct ipc_perm shm_perm; // 共享内存的操作权限
size_t shm_segsz; // 共享内存大小,初始化为size
__time_t shm_atime; // 对这段内存最后一次调用shmat的时间,初始化为0
__time_t shm_dtime; // 对这段内存最后一次调用shmdt的时间,初始化为0
__time_t shm_ctime; // 对这段内存最后一次调用shmctl的时间,初始化为当前时间
__pid_t shm_cpid; // 创建者的PID
__pid_t shm_lpid; // 最后一次执行shmat或shmdt操作的进程的PID,初始化为0
shmatt_t shm_nattach; // 目前关联到此共享内存的进程数量,初始化为0
// 省略其他
};
// shm_perm初始化和sem_perm类似
1
2
3
4
5
6
7
8
9
#include <sys/shm.h>
void* shmat(int shm_id, const void* shm_addr, int shmflg); // 在使用前需要attach到进程的某块地址空间
// 如果shm_addr为NULL则由操作系统选择,这能保证代码的可移植性
// 如果shm_addr非空且SHM_RND未设置,则被关联到指定地址
// 如果shm_addr非空且设置了SHM_RND,被关联的地址是shm_addr-(shm_addr % SHMLBA),即向下圆整到离shm_addr最近的SHMLBA的整数倍地址处,SHMLBA(Segment Low Boundary Address Multiple)是内存页面(PAGE_SIZE)的整数倍,现在Linux内核中恰好相等。
// 除了SHM_RND,shmflg还支持SHM_RDONLY(只读)、SHM_REMAP(如果地址shmaddr已经被关联到一段共享内存上,则取消之前关联的,重新关联)、SHM_EXEC(执行权限,对共享内存而言,执行权限实际上和读权限一样)
// 成功时返回被关联到的地址,并修改内核数据结构shmid_ds(sh_nattach加一,shm_lpid设置为调用进程的PID、shm_atime设置为当前的时间)
int shmdt(const void* shm_addr);// 使用后需要detach
// 成功返回0,同样修改shm_nattach、shm_lpid,此外设置shm_dtime为当前时间
1
2
#include <sys/shm.h>
int shmctl(int shm_id, int command, struct shmid_ds* buf);
shmctl支持的命令
共享内存的POSIX方法

利用mmap和它的MAP_ANONYMOUS可以实现父子进程之间的匿名内存共享。

通过打开同一个文件,mmap也可以实现无关进程之间的内存共享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
// 在进行mmap前,需要先通过shm_open打开shmfd
int shm_open(const char* name, int oflag, mode_t mode);
// 从可移植性考虑,name应是'/'+basename,长度不超过NAME_MAX
// flg与open完全相同,O_RDONLY、O_RDWR、O_CREAT(不存在则创建,低9位为权限,初始长度为0)、O_TRUNC(如果已经存在则截断使其长度为0)

// 之后使用mmap进行内存的共享,失败返回MAP_FAILED
// munmap

int shm_unlink(const char* name);
// 将name参数指定的共享内存对象标记为待删除,当所有使用该共享内存对象的进程都使用munmap将它从进程中分离之后,系统将销毁这个共享内存对象所占据的资源
// 编译时需-lrt
消息队列

消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式,每个数据块都有一个特定的类型,接收方可以根据类型来有选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
// 参数与semget相同
// 如果用于创建消息队列,则与之关联的内核数据结构msqid_ds将被创建并初始化
struct msqid_ds
{
struct ipc_perm msg_perm; // 消息队列的操作权限
time_t msg_stime; // 最后一次调用msgsnd的时间
time_t msg_rtime; // 最后一次调用msgrcv的时间
time_t msg_ctime; // 最后一次被修改的时间
unsigned long __msg_cbytes; // 消息队列中已有的字节数
msgqnum_t msg_qnum; // 消息队列中已有的消息数
msglen_t msg_qbytes; // 消息队列允许的最大字节数
pid_t msg_lspid; // 最后执行msgsnd的进程的PID
pid_t msg_lrpid; // 最后执行msgrcv的进程的PID
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/msg.h>
int msgsnd(int msqid, const void* msg_ptr, size_t msg_sz, int msgflg);
// msgflg通常仅支持IPC_NOWAIT标志
// 默认情况下,发送消息时如果消息队列满了,则msgsnd将阻塞,但如果IPC_NOWAIT标志被指定,则msgsnd将立即返回并设置errno为EAGAIN
// 处于阻塞状态的msgsnd调用可能被两种异常情况终端:
// 消息队列被移除,此时msgsnd调用将立即返回并设置errno为EIDRM
// 程序接收到信号,此时msgsnd调用将立即返回并设置errno为EINTR
// msg_ptr参数指向一个准备发送的消息,消息必须被定义为如下类型
struct msgbuf
{
long mtype; // 必须是正整数
char mtext[512];
};
// 成功时将修改内核数据结构msqid_ds,将msg_qnum加1,将msg_lspid设置为调用进程的PID,将msg_stime设置为当前时间
1
2
3
4
5
6
7
8
#include <sys/msg.h>
int msgrcv(int msqid, void* msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
// msgtype == 0则读取消息队列中的第一个消息
// msgtype > 0 则读取消息队列中第一个类型为msgtype的消息(除非指定了MSG_EXCEPT)
// msgtype < 0 则读取消息队列中第一个类型值比msgtype的绝对值小的消息
// msgflg支持IPC_NOWAIT(没有消息设置ENOMSG)、MSG_EXCEPT(msgtype>0则接收消息队列中第一个非msgtype类型的消息)、MSG_NOERROR(如果消息数据部分的长度超过了msg_sz就将它阶段)
// 处于阻塞状态的msgrcv同样会被两种异常情况中断
// 成功时将修改内核数据结构msqid_ds,将msg_qnum减1,将msg_lrpid设置为调用进程的PID,将msg_rtime设置为当前时间
1
2
#include <sys/msg.h>
int msgctl(int msqid, int command, struct msqid_ds* buf);
msgctl支持的command

IPC命令

ipcs命令可以查看当前系统上有哪些共享资源实例

可以使用ipcrm命令删除遗留在系统中的共享资源

进程间传递文件描述符

只要传递文件描述符的值就好了

多线程编程

线程

NPTL是目前Linux的标准线程库,内核线程与用户线程1:1。

1
2
3
4
5
6
7
8
#include <pthread.h>
int pthread_create(pthread_t* thread, const pthread_attr_t* attr,
void* (*start_routine)(void*), void* arg);
// attr传递NULL表示使用默认线程属性
#include <bits/pthreadtypes.h>
typedef unsigned long int pthread_t;

// C++中使用它时,start_routine必须是static函数

一个用户可以打开的线程数量不能超过RLIMIT_NPROC软资源限制。系统上所有用户能创建的线程总数也不能超过/proc/sys/kernel/threads-max内核参数所定义的值。

线程函数在结束时最好调用如下函数以确保安全、干净地退出

1
2
#inclue <pthread.h>
void pthread_exit(void* retval);

pthread_exit通过retval参数项线程的回收者传递其退出信息,它永远不会失败。

一个进程中的所有线程都可以调用pthread_join函数来回收其他线程(前提是目标线程是可回收的)。

1
2
#include <pthread.h>
int pthread_join(pthread_t thread, void** retval);
pthread_join可能引发的错误码

希望异常终止一个线程(取消线程)时

1
2
#include <pthread.h>
int pthread_cancel(pthread_t thread);

接收到取消请求的目标线程可以决定是否允许被取消以及如何取消

1
2
3
4
5
6
7
8
9
#include <pthread.h>
int pthread_setcancelstate(int state, int* oldstate);
// 设置是否允许取消
// PTHREAD_CANCEL_ENABLE 允许被取消,是默认值
// PTHREAD_CANCEL_DISABLE 禁止被取消。这种情况下,如果一个线程收到取消请求,它会将请求挂起,直到该线程允许被取消
int pthread_setcanceltype(int type, int *oldtype);
// 设置如何取消
// PTHREAD_CANCEL_ASYNCHRONOUS 收到取消请求后立即取消
// PTHREAD_CANCEL_DEFERRED 允许推迟,直到它调用了所谓的取消点函数(pthread_join、pthread_testcancel、pthread_cond_wait、pthread_cond_timedwait、sem_wait、sigwait,根据POSIX标准,其他可能阻塞的系统调用比如read、wait也可以成为取消点,不过为了安全起见,我们最好在可能会被取消的代码中调用pthread_testcancel函数以设置取消点)

pthread_attr_t定义了一套完整的线程属性

1
2
3
4
5
6
7
#include <bits/pthreadtypes.h>
#define __SIZEOF_PTHREAD_ATTR_T 36
typedef union
{
char __size[__SIZEOF_PTHREAD_ATTR_T];
long int __align; // 使得sizeof(long int)字节对齐
}pthread_attr_t;

线程库定义了一系列函数来操作它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <pthread.h>
int pthread_attr_init(pthread_attr_t* attr);
int pthread_attr_destroy(pthread_attr_t* attr);
int pthread_attr_getdetachstate(const pthread_attr_t* attr, int* detachstate);
int pthread_attr_setdetachstate(pthread_attr_t* attr, int detachstate);
int pthread_attr_getstackaddr(const pthread_attr_t* attr, void** stackaddr);
int pthread_attr_setstackaddr(pthread_attr_t* attr, void* stackaddr);
int pthread_attr_getstacksize(const pthread_attr_t* attr, size_t* stacksize);
int pthread_attr_setstacksize(pthread_attr_t* attr , void* stackaddr , size_t stacksize)
int pthread_attr_getguardsize(const pthread_addr_t* __attr , size_t* guardsize);
int pthread_attr_setguardsize(pthread_attr_t* attr , size_t guardsize);
int pthread_attr_getschedparam(const pthread_attr_t* attr , struct sched_param* param);
int pthread_attr_setschedparam(pthread_attr_t* attr , const struct sched_param* param);
int pthread_attr_getschedpolicy(const pthread_attr_t* attr , int* policy);
int pthread_attr_setschedpolicy(pthread_attr_t* attr , int policy);
int pthread_attr_getinheritsched(const pthread_attr_t* attr , int* inherit);
int pthread_attr_setinheritsched(pthread_attr_t* attr , int inherit);
int pthread_attr_getscope(const pthread_attr_t* attr , int* scope);
int pthread_attr_setscope(pthread_attr_t* attr , int scope);

detachstate有两个值,PTHREAD_CREATE_JOINABLE(默认值)和PTHREAD_CREATE_DETACH(脱离线程)。脱离线程在退出时将自行释放其占用的系统资源。可以使用pthread_detach来将线程设为脱离线程。

stack相关的属性是线程堆栈,一般来说不需要自己管理,因为Linux默认为每个线程分配了足够的堆栈空间(一般是8MB)。可以使用ulimt -s来查看或修改该默认值。

guardsize是保护区大小,如果guardsize大于0,则系统创建线程的时候会在其堆栈的尾部额外分配guardsize字节的空间,作为保护堆栈不被错误地覆盖的区域。如果使用者通过pthread_attr_setstackaddr或pthread_attr_setstack函数手动设置线程的堆栈,则guardsize属性将被忽略。

schedparam是线程调度参数,它的结构体目前只有一个整型成员sched_priority。

schedpolicy是线程调度策略,有SCHED_FIFO、SCHED_RR、SCHED_OTHER(默认值)。前两种只能用于以超级用户身份运行的进程。

inheritsched,是否继承调用线程的调度属性,有PTHREAD_INHERIT_SCHED和PTHREAD_EXPLICIT_SCHED两个值。前者表示继承,这种情况下再设置新的调度参数属性将没有任何效果,后者表示调用者要明确地指定新线程的调度参数。

scope,线程间竞争CPU的范围,即线程优先级的有效范围。POSIX定义了PTHREAD_SCOPE_SYSTEM和PTHREAD_SCOPE_PROCESS两个可选值,前者表示所有线程一起竞争,后者表示仅与属于同一进程的线程竞争CPU。目前Linux只支持PTHREAD_SCOPE_SYSTEM。

POSIX信号量

常用的POSIX信号量

1
2
3
4
5
6
7
8
9
int sem_init(sem_t* sem , int pshared , unsigned int value);
// pshared指定信号量类型,如果为0则是当前进程的局部信号量,否则就可以在多个进程间共享
// 初始化一个已经被初始化的信号量将导致不可预期的结果
int sem_destroy(sem_t* sem);
// 如果销毁一个正被其他线程等待的信号量将导致不可预期的结果
int sem_wait(sem_t* sem);
int sem_trywait(sem_t* sem);
// 非阻塞,立即返回,信号量值为0则设置EAGAIN
int sem_post(sem_t* sem);

互斥锁

1
2
3
4
5
6
7
8
9
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t* mutex , const pthread_mutexattr_t* mutexattr);
// mutexattr为NULL则设为默认属性
int pthread_mutex_destroy(pthread_mutex_t* mutex);
// 销毁一个已经加锁的互斥锁将导致不可预期的后果
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
// 非阻塞,错误码EBUSY
int pthread_mutex_unlock(pthread_mutex_t* mutex);

还可以使用下面的方式初始化 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

获取和设置互斥锁属性的函数有

1
2
3
4
5
6
7
8
9
10
11
#include<pthread.h>
/*初始化互斥锁属性对象*/
int pthread_mutexattr_init(pthread_mutexattr_t* attr);
/*销毁互斥锁属性对象*/
int pthread_mutexattr_destroy(pthread_mutexattr_t* attr);
/*获取和设置互斥锁的pshared属性*/
int pthread_mutexattr_getpshared(const pthread_mutexattr_t* attr , int* pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t* attr , int pshared);
/*获取和设置互斥锁的type属性*/
int pthread_mutexattr_gettype(const pthread_mutexattr_t* attr , int* type);
int pthread_mutexattr_settype(pthread_mutexattr_t* attr , int type);

属性pshared指定是否允许跨进程共享互斥锁,PTHREAD_PROCESS_SHARED和PTHREAD_PROCESS_PRIVATE

属性type指定互斥锁类型

  • PTHREAD_MUTEX_NORMAL,普通锁,是默认类型,当一个线程对一个普通锁加锁以后,其余请求该锁的线程将形成一个等待队列,并在该锁解锁后按优先级获得它。这种锁类型保证了资源分配的公平性。但这种锁也容易引发问题:一个线程如果对一个已经加锁的普通锁再次加锁,将引发死锁;对一个已经被其他线程加锁的普通锁解锁,或者对一个已经解锁的普通锁再次解锁,将导致不可预期的后果。
  • PTHREAD_MUTEX_ERRORCHECK,检错锁。一个线程如果对一个已经加锁的检错锁再次加锁,则加锁操作返回EDEADLK。对一个已经被其他线程加锁的检错锁解锁,或者对一个已经解锁的检错锁再次解锁,则解锁操作返回EPERM。
  • PTHREAD_MUTEX_RECURSIVE,嵌套锁。这种锁允许一个线程在释放锁之前多次对它加锁而不发生死锁。不过其他线程如果要获得这个锁,则当前锁的拥有者必须执行相应次数的解锁操作。对一个已经被其他线程加锁的嵌套锁解锁,或者对一个已经解锁的嵌套锁再次解锁,则解锁操作返回EPERM。
  • PTHREAD_MUTEX_DEFAULT,默认锁。一个线程如果对一个已经加锁的默认锁再次加锁,或者对一个已经被其他线程加锁的默认锁解锁,或者对一个已经解锁的默认锁再次解锁,将导致不可预期的后果。这种锁在实现的时候可能被映射为上面三种锁之一。

条件变量

条件变量用于在线程之间同步共享数据的值。条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<pthread.h>
int pthread_cond_init(pthread_cond_t* cond , const pthread_condattr_t* cond_attr);
// 如果cond_attr设置为NULL则使用默认属性
int pthread_cond_destroy(pthread_cond_t* cond);
// 销毁一个正在被等待的条件变量将失败并返回EBUSY
int pthread_cond_broadcast(pthread_cond_t* cond);
// 以广播的方式唤醒所有等待目标条件变量的线程
int pthread_cond_signal(pthread_cond_t* cond);
// 唤醒一个
int pthread_cond_wait(pthread_cond_t* cond , pthread_mutex_t* mutex);
// mutex是用于保护条件变量的互斥锁,以确保pthread_cond_wait操作的原子性。在执行该函数前,必须确保mutex已经加锁,否则将导致不可预期的结果
// 在执行时,首先把线程放入条件变量的等待队列,然后将互斥锁mutex解锁。mutex可以保证在函数开始执行到其调用线程被放入条件变量的等待队列之间的这段时间条件变量不会被其他函数修改
// 成功返回时,mutex将再次被锁上

同样可以通过pthread_cond_t cond = PTHREAD_COND_INITIALIZER;初始化。

有时候我们可能想唤醒一个指定的线程,但pthread没有对该需求提供解决方法。不过我们可以间接地实现该需求:定义一个能够唯一表示目标线程的全局变量,在唤醒等待条件变量的线程前先设置该变量为目标线程,然后采用广播方式唤醒所有等待条件变量的线程,这些线程被唤醒后都检查该变量以判断被唤醒的是否是自己,如果是就开始执行后续代码,如果不是则返回继续等待。

线程同步机制包装类

可以将这些同步机制分别封装成类方便复用代码,符合RAII。

多线程环境

线程和进程

子进程并不会复制父进程的所有进程,而是复制调用fork的那个线程,并且自动继承父进程中互斥锁、条件变量的状态。这引起了一个问题,子进程可能不清楚从父进程继承而来的互斥锁的具体状态,这个互斥锁可能被加锁了,但并不是由调用fork函数的那个线程锁住的,而是由其他线程锁住的。pthread提供了一个专门的函数pthread_atfork以确保fork调用后父进程和子进程都拥有一个清楚的锁状态。

1
2
3
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
// 调用多次会注册多个函数,prepare按照注册时的逆序,parent和child按注册时的顺序

prepare句柄将在fork调用创建出子进程之前被执行,它可以用来锁住所有父进程中的互斥锁。parent句柄则是fork调用创建出子进程之后,而fork返回之前,在父进程中被执行。它的作用是释放所有在prepare句柄中被锁住的互斥锁。child句柄是fork返回之前,在子进程中被执行,也是用于释放所有在prepare句柄中被锁住的互斥锁。

当其他线程加锁时,prepare试图acquire会被阻塞,直到其他线程释放锁,此时prepare能正常获得锁,然后在parent和child中分别释放,就能使得其他线程锁的状态都能释放。

线程和信号

在多线程环境下,设置进程信号掩码时不应用sigprocmask,而应使用下面的

1
2
3
#include <pthread.h>
#include <signal.h>
int pthread_sigmask(int how, const sigset_t* newmask, sigset_t* oldmask);

进程中的所有线程共享该进程的信号,线程库将根据线程掩码决定把信号发送给哪个具体的线程。所有线程共享信号处理函数,当我们在某个线程中设置了某个信号的信号处理函数后,它将覆盖其他线程为同一个信号设置的信号处理函数。因此,一般情况下应定义一个专门的线程来处理所有的信号避免意外出错。这可以通过如下两个步骤来实现:

  1. 在主线程创建出其他子线程之前就调用pthread_sigmask来设置好信号掩码,这样所有新创建的子线程都将自动继承这个信号掩码,所有线程都不会响应被屏蔽的信号
  2. 在专门的线程处理函数中调用如下函数
1
2
#include <signal.h>
int sigwait(const sigset_t* set, int* sig);

可以使用下面的函数将信号发送给指定的线程。

1
2
3
#include <signal.h>
int pthread_kill(pthread_t thread, int sig);
// sig是0时不发送信号,但仍执行错误检查,可以用来检测目标线程是否存在

进程池和线程池

池的子进程的数目一般在3~10个之间,子线程的数目应该和CPU数量差不多。

在服务器启动之初就创建好可以使得子进程没有复制一些不必要的空间,占用资源较少。

服务器调制、调试和测试

最大文件描述符数

ulimit -n查看用户级文件描述符数限制

ulimit -SHn <max-file-number>临时修改

永久修改需要在/etc/security/limits.conf中(分别修改硬限制和软限制)

hard nofile

soft nofile


如果要修改系统级文件描述符限制,可使用sysctl -w fs.file-max=<max-file-number>(临时)

永久修改需在/etc/sysctl.conf中

fs.file-max=

然后执行sysctl -p

调整内核参数

几乎所有的内核模块,包括内核核心模块和驱动程序,都在/proc/sys文件系统下提供了某些配置文件以供用户调整模块的属性和行为,通常一个配置文件对应一个内核参数,文件名就是参数的名字,文件的内容是参数的值,我们可以通过命令sysctl -a查看所有这些内核参数。可以通过直接修改/proc/sys目录下的文件的方式来修改这些系统参数外,也可以使用sysctl 命令来修改它们。这两种修改方式都是临时的。

要永久修改应在/etc/sysctl.conf 文件中加入相应参数及其数值,并执行sysctl -p使之生效。

文件相关

/proc/sys/fs目录下的内核参数都与文件系统相关。

/proc/sys/fs/fs/file-max,系统级文件描述符数限制,修改这个参数是临时修改。一般修改/proc/sys/fs/file-max 后,应用程序需要把/proc/sys/fs/inode-max 设置为/proc/sys/fs/fs/file-max 值的3-4倍,否则可能导致i 节点数不够用。

/proc/sys/fs/epoll/max_user_watches,一个用户能够往epoll 内核事件表注册的事件总量。 它是指该用户打开的所有epoll实例总共能监听的事件数目,而不是单个epoll实例能监听的事件数目。往epoll内核事件表中注册一个事件,在32位系统上大概消耗90字节的内核空间,在64位系统上则消耗160字节的内核空间。所以,这个内核参数限制了epoll使用的内核内存总量。

网络相关

内核中网络模块的相关参数都位于/proc/sys/net 目录下,其中和TCP/IP 协议相关的参数主要位于如下三个目录中:core 、ipv4 、ipv6 。

/proc/sys/net/core/somaxconn,指定listen监听队列里,能够建立完整连接从而进入ESTABLISHED 状态的socket 的最大数目。

/proc/sys/net/ipv4/tcp_max_syn_backlog,指定listen监听队列里,能够转移至ESTABLISHED或者SYN_RCVD状态的socket的最大数目。

/proc/sys/net/ipv4/tcp_wmem,它包含了3个值,分别指定一个socket的TCP写缓存区的最小值、默认值和最大值。

/proc/sys/net/ipv4/tcp_rmem,它包含了3个值,分别指定一个socket的TCP读缓存区的最小值、默认值和最大值。

/proc/sys/net/ipv4/tcp_syncookies,指定是否打开TCP同步标签。同步标签通过启动cookie 来防止一个监听socket因不停的重复接收来自同一个地址的连接请求(同步报文段),而导致listen监听队列溢出(所谓的SYN 风暴)。

gdb调试

调试多进程

单独调试子进程

运行gdb,在gdb里执行attach <pid>

follow-fork-mode

在gdb里set follow-fork-mode <mode>,mode为parent或child,选择程序在执行fork后调试父进程还是子进程。

调试多线程

info threads显示当前所有可调试的线程,gdb会为每一个线程分配一个ID,根据ID来操作对应的线程,ID前有“*”的是当前被调试的线程。

thread <ID>调试目标进程

在调试多线程程序时,默认除了被调试的线程在执行外,其他线程也在继续执行。可以通过set scheduler-locking [off|on|step]设置其他线程的运行状态。off表示不锁定任何线程,这是默认值,on表示只有当前被调试的线程会继续执行,step表示在单步执行的时候只有当前线程会执行。

压力测试

如果是本机测试,可以单纯用I/O复用,因为多线程和多进程本身的调度也要消耗大量时间。

系统检测工具

tcpdump

网络抓包

1
2
3
4
5
6
7
8
9
10
11
12
13
-n # 使用IP地址表示主机,而不是主机名;使用数字表示端口号,而不是服务名称
-i # 指定要监听的网卡接口。-i any表示抓取所有网卡接口上的数据包
-v # 输出一个稍微详细的信息,例如显示IP数据包中的TTL和TOS信息
-t # 不打印时间戳
-e # 进现实以太网帧头部信息
-c # 仅抓取指定数量的数据包
-x # 以十六进制显示数据包的内容,但不显示包中以太网帧的头部信息
-X # 与-x类似,但还打印每个十六进制字节对应的ASCII字符
-XX # 与-X相同,不过还打印以太网帧的头部信息
-s # 设置抓包时的抓取长度
-S # 以绝对值来显示TCP报文段的序号,而不是相对值
-w # 将tcpdump的输出以特殊的格式定向到某个文件
-r #从文件读取数据包信息并显示之

还支持用表达式进一步过滤数据包,操作数分三种,type(类型,包括host、net、port和portrange),dir(方向),proto(协议)

tcpdump net 1.2.3.0/24

tcpdump dst port 13579

tcpdump icmp

tcpdump ip host ernest-laptop and not Kongming20

可以用括号改变优先级,但括号存在时应把tcpdump后面的整个表达式用单引号引起来,或用反斜杠对括号进行转义。

tcpdump 'src 10.0.2.4 and (dst port 3389 or 22)'

此外还允许直接使用数据包中的部分协议字段的内容来过滤数据包,比如仅抓取TCP同步报文段

tcpdump 'tcp[13] & 2 != 0'(TCP头部第14个字节的第2个位是同步标志)

tcpdump 'tcp[tcpflags] & tcp-syn != 0'

lsof

列出当前系统打开的文件描述符

nc

主要被用来快速构建网络连接,以调试客户端、服务器程序。

strace

测试服务器性能的重要工具,跟踪程序运行过程中执行的系统调用和接收到的信号,并将系统调用名、参数、返回值及信号名输出到标准输出或指定文件。

netstat

功能强大的网络信息统计工具,可以打印本地网卡接口上的全部连接、路由表信息、网卡接口信息。不过获取路由表信息和网卡接口信息更实用的是route和ipconfig。

vmstat

是virtual memory statistics的缩写,能实时输出系统的各种资源的使用情况,比如进程信息、内存使用、CPU使用率以及I/O使用情况

可以使用iostat获得磁盘使用情况的更多信息,也可以使用mpstat获得CPU使用的更多信息。vmstat主要用于查看系统内存的使用情况。

ifstat

是interface statistics的缩写,是一个简单的网络流量监测工具。使用ifstat命令可以大概估计各个时段服务器的总输入、输出流量。

mpstat

是multi-processor statistics的缩写,能实时监测多处理器系统上每个CPU的使用情况。mpstat和iostat命令通常都集成在sysstat中。


Linux高性能服务器编程笔记
https://jhex-git.github.io/posts/3589769194/
作者
JointHex
发布于
2023年2月11日
许可协议