第五章 Linux网络编程基础API
5.1 socket地址API
- 主机字节序和网络字节序
主机通常采用小端;
网络总是采用大端; - 主机字节序和网络字节序的转换
#include<netinet/in.h> htonl(unsigned long int ..); // host to network long htons(unsigned short int ...); // host to network shoet ntohl(...); ntohs(...);
- 通用socket地址
使用sockaddr
#include<bits/socket.h> struct sockaddr{ sa_family_t sa_family; // 地址族类型 => UNIX本地域协议族、TCP/IPv4、TCP/IPv6 char sa_data[14]; // socket地址值; 对于有的地址族,14字节不够 => 使用新的地址表示sockaddr_storage }
- 专用socket地址
这里只列出tcp/ip4使用的专用socket地址结构体sockaddr_in
:
注意:所有专用socket地址只是为了用户方便,struct sockaddr_in{ sa_family_t sin_family; // 地址族,填写AF_INET u_int16_t sin_port; // 端口号,要用大端表示!!! struct in_addr sin_addr; // IPv4地址,这个结构体其实只包含一个32位无符号整型 }
真正使用时仍然需要强制转换为通用的sockaddr
- IP地址转换函数
字符串地址 => 大端表示的网络地址:inet_addr
、inet_aton
大端表示的网络地址 => 字符串地址:in_addr_t inet_addr(const char* strprt); int inet_aton(const char* cp,struct in_addr* inp);
inet_ntoa
注意:1.char* inet_ntoa(struct in_addr in);
inet_ntoa是不可重入的
; 2.还有更新的转换函数:inet_pton
、inet_ntop
5.2 创建socket
- API
domain:协议族,对于TCP/IPv4,通常是AF_INETint socket(int domain, int type,int protocol);
type:服务类型,TCP是SOCK_STREAM
、UDP是SOCK_UGRAM
protocol:通常为0,几乎不用过多考虑 - 非阻塞socket
type设置为SOCK_NONBLOCK
可将新建的socket指定为非阻塞
5.3 命名socket
- API
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
socket命名
:即将socket地址与socket对应的文件描述符绑定;服务端必须显式绑定,客户端则是自动绑定的
5.4 监听socket
- API
listen
的主要作用就是为监听描述符sockfd创建监听队列
sockfd:被监听的描述符;int listen(int socketfd, int backlog);
backlog:内核监听队列的最大长度(仅指完全连接状态的socket),典型值是5
5.5 接受连接
- API
accept只负责从监听队列取出连接,不管连接是处于什么状态!
sockfd:listen调用过的监听socket;int accept(int sockfd, struct sockaddr* addr, socklen_t addrlen);
addr:客户端连接的socket地址
;
返回值:连接socket !!!
5.6 发起连接
- API
发起连接通常是由客户端调用:
返回:连接socket,客户端通过读写这个连接socket来与服务端通信!int connect(int sockfd,const struct sockaddr* serv_addr, socklen_t addrlen);
5.7 关闭连接
- close
int close(int fd);
只是将fd的引用计数减1,只有当fd减为0时才真正关闭连接
- shutdown
shutdown无论如何都能立即终止连接
howto:关闭的方式(关闭读的一半、写的一半、还是都关闭)int shutdown(int sockfd, int howto);
5.8 数据读写
-
TCP数据读写recv、send
ssize_t recv(int sockfd, void* buf, size_t len, int flags); ssize_t send(int sockfd, void* buf, size_t len, int flags);
buf:要读/写数据的内存地址;
len:要读/写数据的长度;
flags:通常为0 => 特使值MSG_OOB
代表紧急数据
返回:实际读/写的字节数 =>若recv返回0,代表通信对方已关闭连接
-
UDP数据读写recfrom、sendto
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen); ssize_t sendto(int sockfd, void* buf, size_t len, int flags, struct sockaddr* dest_addr, socklen_t addrlen);
buf:要读/写数据的内存地址;
len:要读写数据的长度;
flags:含义与TCP收发数据相同……
注:recvfrom、sendto也能用于面向连接的数据读写(STREAM)
,只需将最后两个参数都设置为NULL即可 -
通用数据读写recvmsg、sendmsg
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地址 socklen_t msg_namelen; // socket地址的长度 struct iovec* msg_iov; // 分散的内存块,重要,待学习... int msg_iovlen; // 分散内存块的数量 void* msg_control; // 指向辅助数据的起始位置 socklen_t msg_controllen; // 辅助数据的大小 int msg_flags; // 复制的函数参数中的flags,会在调用过程中更新 }
注意:
对于TCP而言,msghdr中不需要填写对方的socket地址,必须设置为NULL
分散读:读取的数据将存在msg_iov指向的那些散的内存块中;
集中写:写数据时msg_iov指定的分散内存块中的数据将一并发送…
关于iovec
更多内容参考第六章(6.3)……
5.9 带外标记
- 通知带外数据到达的方式
内核通知应用程序带外数据到达的两种方式:IO复用产生的异常事件
、SIGURG信号
- 如何检测带外数据在数据流中的位置
若返回0,表示sockfd中没有带外数据;int sockatmark(int sockfd);
若返回1 => 可以进一步利用MSG_OOB标志的recv调用来接收带外数据
5.10 地址信息函数
- API
int getsockname(int sockfd,struct sockaddr* address, socklen_t* address_len);
int getpeername(int sockfd,struct sockaddr* address, socklen_t* address_len);
getsockname
:获取本端socket地址;
getpeername
:获取远端socket地址;
5.11 socket选项
- 控制socket选项
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, void* option_value,socklen_t* restrict option_len);
level
:指定要操作哪个协议的选项,比如TCP、IPv4、IPv6;
option_name:选项的名字; - SO_REUSEADDR
强制使用被处于TIME_WAIT状态的连接占用的socket地址
; - SO_RCVBUF、SO_SNDBUF
SO_RCVBUF:设置TCP的接收缓冲区大小,如果输入的值为val,那么系统最终设置的接收缓冲区为:2 *val > T1? 2 *val : T1
;
SO_SNDBUF:设置TCP的发送缓冲区大小,如果输入的值为val,那么系统最终设置的发送缓冲区为:2 *val > T2? 2 *val : T2
;
注:TCP发送缓冲区的最小256字节(T2),接收缓冲区最小2048字节(T1); - SO_LINGER
若有数据待发送,则延迟关闭……详见p94
5.12 网络信息API
-
gethostbyname、gethostbyaddr
struct hostent* gethostbyname(const char* name); struct hostent* gethostbyaddr(const void* addr, size_t len, int type); struct hostent{ char* h_name; // 主机名 char** h_aliases; // 主机别名 => 可能有多个 int h_addrtype; // 地址类型(地址族) int h_length; // 地址长度 char** h_addr_list; // 按照网络字节序输出的主机IP地址列表 }
gethostbyname
:根据主机名获取主机完整信息;
gethostbyaddr
:根据主机地址获取主机完整信息
这两个函数都是不可重入
的! -
getservbyname、getservbyport
struct servent* getservbyname(const char* name, const char* proto); struct servent* getservbyport(int port,const char* proto); struct servent{ char* s_name; // 服务名称 char** s_aliases; // 服务的别名列表 => 可能多个 int s_port; // 服务占用的端口号 char* s_proto; // 服务类型 => 通常是TCP或者UDP }
应该都是只能访问本机的服务!!!
都是不可重入
的 -
getaddrinfo
既能通过主机名获得IP地址 => 内部调用gethostbyname
;
也能通过服务名获得端口号 => 内部调用getservbyname
int getaddrinfo(const char* hostname, const char* service, const strcut addrinfo* hints, struct addrinfo** result); struct addrinfo{ int ai_flags; // int ai_family; // 地址族 int ai_socktype; // 服务类型 => SOCK_STREAM或者SOCK_DGRAM socklen_t ai_address;//socket地址ai_addr的长度 char* ai_cononname; // 主机的别名 struct sockaddr* ai_addr; // 指向socket地址 struct addrinfo* ai_next; // 指向下一个sockinfo结构的对象 }
hostname
:主机名或者字符串表示的IP地址;
service
:服务名或者字符串表示的十进制端口号;
注1:getaddrinfp将隐式分配堆内部内存(result指针),所以需要手动释放:void freeaddrinfo(struct addrinfo* res);
注2:getaddrinfo是否可重入取决于它调用的gethostbyname、getservbyname是否为可重入版本
-
getnameinfo
既能通过socket地址获得字符串表示的主机名 => 调用gethostbyaddr
;
又能同时获得服务名 => 调用getservbyport
int getnameinfo(const struct sockaddr* sockaddr, socklen_t socklen, char*host, socklent_t hostlen, char* serv, socklen_t servlen, int flags);
是否可重入区别于调用的gethostbyaddr、getservbyport是否为可重入版本
第六章 高级I/O函数
6.1 pipe与socketpair函数
-
pipe
#include<unistd.h> int pipe(int fd[2]);
fd[1]用于写,fd[0]用于读;
默认情况下这一对描述符都是阻塞的
;
管道内部传输的数据是字节流,但管道本身有容量限制 -
socketpair
int sockpair(int domain, int type, int protocol, int fd[2]);
前三个参数与socket系统调用一致;
但是domain只能使用AF_UNIX,因为仅能本地使用这个双向管道
;
返回的这对描述符都是既可读又可写的
!!!
6.2 dup和dup2函数
- dup
返回一个新的文件描述符,与file_descriptor指向相同的文件/管道/网络连接;int dup(int file_descriptor);
返回的文件描述符总是取当前系统可用的最小值; - dup2
返回一个新的文件描述符,与file_descriptor_one指向相同的文件/管道/网络连接;int dup2(int file_descriptor_one,int file_descriptor_two);
但是返回的fid不能小于file_descriptor_two - 补充
作用:常用于将标准输入重定向到一个文件; 或者将标准输出重定向到一个网络连接,如CGI
(实现方法就是先关闭标准输出,然后dup连接描述符,此时返回的fid为1……)
注意:dup和dup2返回的文件描述符不继承原文件描述符的特性,比如non-blocking等
6.3 readv和writev函数
- readv
将数据从文件描述符读到分散的内存块中,即分散读
结构体iovec描述一块内存区域; 参数count是vector数组的长度;ssize_t readv(int fd, const struct iovec* vector, int count);
相当于简化版的recvmsg - writev
将多块分散的内存数据一并写入文件描述符,即集中写
相当于简化版的sendmg;ssize_t writev(int fd, const struct iovec* vector, int count);
writev使用举例:HTTP相应报文包含状态行、头部、空行、响应内容,这几个部分可以使用writev同时写出,而不必拼接到一起再发送!!!
6.4 sendfile函数(零拷贝)
- sendfile
sendfile在两个文件描述符之间直接传递数据,完全在内核中操作,从而避免了内核缓冲区和用户缓冲区之间的数据拷贝
,称为零拷贝ssize_t sendfile(int out_fd, int in_fd,off_t offset, size_t count);
in_fd必须支持类似mmap函数的文件描述符,即它必须指向真实的文件
,不能是socket或管道;
out_fd必须是一个socket
;
=> sendfile专用于网络传输!!!
6.5 mmap和munmap函数
- mmap
mmap用于申请一段内存空间。
1.可以将这段内存作为进程间通信的共享内存
;
2.也可以将文件直接映射到其中
start:用户指定的内存起始地址,设为NULL则自动分配;void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
length:要分配的内存段长度;
fd:这段内存要被映射的文件;
offset:从文件的何处开始映射 - munmap
释放mmap分配的内存……int munmap(void* start, size_t length);
6.6 splice函数(零拷贝)
- splice
也是用于在两个文件描述符之间移动数据,同样是零拷贝
fd_in对应源文件,若它是管道,则off_in必须为NULL,否则off_in表示从文件的哪个偏移开始拷贝;ssize_t splice(int fd_in,loff_t* off_in, int fd_out, loff_t off_out, size_t len, unsigned int flags);
fd_out和off_out同理……;
fd_in和fd_out必须至少有一个是管道
;
6.7 tee函数(零拷贝)
- tee
tee在两个管道描述符之间拷贝数据
,也是零拷贝ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
6.8 fcntl函数
- fcntl
提供对文件描述符的控制操作,类似于ioctl,但是ioctl比fcntl能执行更多的控制操作
示例:将描述符设置为非阻塞=>int fcntl(int fd, int cmd,...);
int setnonblocking(int fd){ int old_option=fcntl(fd,F_GETFL); // 获取fd的状态标志 int new_option=old_option|O_NONBLOCK; // 设置非阻塞标志 fcntl(fd,F_SETFL,new_option); return old_option; }
第七章 Linux服务器程序规范
7.0 概述
- 后台进程
Linux服务器程序一般以后台进程形式运行,又称守护进程(daemon),它没有控制终端
,因而不会意外接收到用户输入,守护进程的父进程通常是init进程(PID为1)
- 日志系统
大部分后台进程都在/var/log
目录下拥有自己的日志目录 - 非root身份运行
Linux服务器程序一般都以某个专门的非root身份运行
。比如mysqld、httpd、syslogd等后台进程
分别拥有自己的运行账户mysql、spache、syslog - 可配置
多数服务器程序都有配置文件且存放在/etc
目录下 - PID文件
Linux服务器进程通常在启动时生成一个PID文件并存入/var/run
目录下,比如/var/run/syslogd.pid
7.1 日志
- Linux日志系统syslogd/rsyslogd
rsyslog是syslog程序的升级版;
用户日志输出:用户进程调用syslog函数
将日志输出到一个UNIX本地域socket类型的文件/dev/log中
,syslog/rsyslog则监听该文件以获取该文件的输出;
内核日志输出:内核日志由printk等函数
打印至内核的环状缓存(ring buffer)中,环状缓存的内容直接映射到/proc/kmsg文件
,syslog/rsyslog读取该文件获得内核日志;
- syslog函数
应用程序使用syslog
函数与rsyslogd守护进通信void syslog(int priority, const char* message,...); int setlogmask(int maskpri); // 设置日志掩码,过滤级别大于掩码的日志信息 void closelog(); // 关闭日志功能
7.2 用户信息
- UID、EUID、GID、EGID
每个进程拥有以下几个用户/组相关的ID……
UID:真实用户ID
EUID:有效用户ID
GID:真实组ID
EGID:有效组ID
有效ID的作用:uid_t getuid(); // 获取真实和用户ID uid_t geteuid(); // 获取有效用户ID gid_t getgid(); // 获取真实组ID gid_t getegid(); // 获取有效组ID int setuid(uid_t uid); // 设置真实用户ID int seteuid(uid_t uid); // 设置有效用户ID int setgid(gid_t gid); // 设置这是组ID int setegid(gid_t gid); // 设置有效组ID
有效ID使得进程的真实用户拥有有效用户的权限
(但是不同于sudo,sudo执行程序时,真实用户和有效用户都是root)
7.3 进程间关系
-
进程组
每个进程都隶属于一个进程组;进程组PGID就是组内首领进程的PID
;
进程组将一直存在,直到其中所有进程都退出,或者加入到其他进程组;
一个进程只能设置自己或者其子进程的PGID
;pid_t getpgid(pid_t pid); // 返回进程pid所在组的PGID int setpgid(pid_t pid, pid_t pgid); // 将进程pid的组设为pgid
-
会话
一些有关联的进程组形成一个会话
(好吧,这块不太了解,待学习……)
-
ps命令查看进程关系
ps(process status)
,类似于window的任务管理器……ps -o pid,ppid,pgid,sid,comm | less
上面的示例查看进程、组、会话之间的关系
7.4 系统资源限制
- 概述
各类资源都会受到一定的限制(文件名长度、CPU、系统策略……),系统资源限制的访问/修改接口为:
上面int getrlimit(int resource, struct rlimit* rlim); int setrlimit(int resource, const struct rlimit* rlim); struct rlimit{ // rlim_t是整型 rlim_t rlim_cur; rlim_t rlim_max; };
resource参数指定资源类型
;
rlim_cur代表软限制:即建议性的,最好不要超过的限制,否则可能被终止;
rlim_max代表硬限制:即软限制的上限
7.5 改变工作目录和根目录
- 概述
1.char* getcwd(char* buf, size_t size); // 获取进程当前工作目录 int chdir(const char* path); // 将进程工作目录切换到path int chroot(const char* path); // 将进程根目录切换到path
chroot()并不改变进程的当前工作目录,所以调用chroot后仍然要使用chdir()来将工作目录切换到新的根目录
;
2.改变进程根目录后,无法访问/dev等路径,因为这些路径并非出于新的根目录之下
3. 不过切换根目录之前的文件描述符仍然能正常访问原来的文件/目录;
4. 只有特权进程才能切换根目录
7.6 服务器程序后台化
- 概述
nochdir:用于指定是否改变当前工作目录。若为0,则将当前工作目录改为“/”;否则继续使用当前工作目录int daemon(int nochdir, int noclose);
noclose:如果如0,则标准输入/输出/错误都将被重定向到/dev/null
,否则依然使用原来的设备 =>通常后台进程都需要将stdin/out/err重定向到/dev/null,即后台进程一般都没有控制终端
第八章 高性能服务器程序框架(本书核心)
- 概述
服务器程序可分为三个模块:
IO处理单元:四种IO模型,两种事件处理模型;
逻辑单元:即为连接服务的程序 => 两种高效并发模式+有限状态机;
存储:本书不讨论……
8.1 服务器模型
- C/S模型
不用过多描述……
C/S模型适用于资源相对集中的场合;但是服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应 - P2P模型
P2P模型中每台机器在消耗服务的同时也在给别人提供服务
;
云计算机群可以看做是P2P的一个典范;
缺点:当用户见传输请求过多时,网路负载将加重;
从编程角度来将,P2P模型可以看做C/S模型的扩展
8.2 服务器编程框架
- 系统框架
IO处理单元:服务器管理客户连接的模块,完成的工作包括:等待并接受新的客户连接 => 接收客户数据 => 将服务器响应数据返回给客户端
逻辑单元:通常是一个进程或者线程,它是为IO处理单元建立的连接服务的程序 ;它分析并处理客户数据,然后将结果传递给IO处理单元或者直接发送给客户端
网络存储单元:数据库、缓存、文件等
请求队列
:它是各单元间通信的方式(队列很重要)!!!
8.3 I/O模型(重要) TODO
-
IO中阻塞/非阻塞的理解
本章讨论的IO基本都是网络IO,他们可以是阻塞的/非阻塞的。而常规文件IO一般都是非阻塞的
;
阻塞
是指线程在内核态被放入阻塞队列,我们看到用户态的接口函数没有立即向下执行时,不一定就是发生了阻塞,因为这时候线程可能处于内核态正工作着;
以多路复用为例,我们说某个网络IO是阻塞的,应该指它在内核轮询(select、poll)完成后仍未发现数据准备好(epoll并非轮询的方式),于是将线程阻塞; -
socket本身的阻塞与非阻塞
socket本身在创建的时候默认是阻塞的;
可以设置SOCK_NONBLOCK标志是socket为非阻塞;
阻塞/非阻塞的概念能引用与所有文件描述符,而不仅是socket
-
1.阻塞IO
关于五大IO模型,本书部分内容与本人立即不太相符,最好参考UNP内容…… -
2.非阻塞IO
-
3.IO多路复用
-
4.信号
-
5.异步IO
8.4 两种高效的事件处理模式(Reactor、Proactor)
-
事件
三类事件:IO事件
、信号事件
、定时事件
如何响应这些事件,主要有两种方式,即本节介绍的事件处理模式:Reactor、Proactor
惯例:同步IO模型通常用于实现Reactor模式;异步IO模型常用于实现Proactor -
Reactor模式(工作线程既负责逻辑处理,也负责读写数据)
reactor模式:主线程(IO处理单元)只负责监听文件描述符上是否有事件发生
,有就立即通知工作线程(逻辑单元);读写数据/接受连接/处理客户请求均在工作线程中完成
-
Preactor(工作线程仅负责逻辑处理)
preactor模式:将所有的IO操作都交给主线程和内核线程来处理
(注意这里内核线程的作用),工作线程仅仅负责业务逻辑
-
模拟Proactor模式
使用同步IO方式模拟出Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一完成事件
详见p130……
8.5 两种高效的并发模式(半同步半异步、领导者追随者)
-
相关概述
计算密集型:对于计算密集的程序,并发编程并无优势,反而由于任务的切换降低效率;
IO密集型:让程序阻塞于IO将浪费大量CPU时间
,比如经常读写文件/访问数据库 => 此时适合并发编程
并发模式的概念:指IO处理单元和多个逻辑单元之间协调完成任务的方法
两种并发编程模式:半同步/半异步
、领导者/追随者
-
1.半同步/半异步
并发模式中的同步异步
如图,异步线程效率更高,实时性强……半同步/半异步模式
半同步/半异步模式中,异步线程用于处理IO事件,同步线程用于处理客户逻辑
。异步线程监听到客户请求后,就将其插入到请求队列。然后某个工作在同步模式的某个线程来读取并处理该请求变种1:半同步/半反应堆(reactor)
这种情况下,异步线程只有一个,由主线程来充当,它负责监听有socket上的事件
=> 如果是监听socket上的事件,则接受并得到新连接socket;如果是连接socket上的事件,则将该连接socket插入请求队列……
由图知,主线程只负责监听,读写/逻辑处理由工作线程负责 => 可见其事件处理模式是Reactor模式!!!
缺点:1.主线程和工作线程共享请求队列,需要频繁加锁/释放锁;每个工作线程在同一时间只能处理一个客户请求变种2:高效的半同步/半异步模式
这种情况下,主线程只管理监听socket,连接socket由工作线程来管理
。当有新请求到来时,主线程接受它并将对应的连接socket派发给某个工作线程,此后该连接socket上的任何IO操作都由被选中的工作线程来处理
(派发socket的最简方式,是往主线程和工作线程之间的管道里写数据)
这种情况下,不止主线程,工作线程也有自己的主循环,从而它可以同时处理多个客户连接
(这就是其高效之处);
所以,这种模式下,其实所有线程都工作在异步模式!!! -
2.领导者/追随者模式
概念:它是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。当前的领导者如果检测到IO事件,首先要从线程池中推选出新的领导者线程,然后再处理IO事件
;它主要包含以下几个组件:
句柄集:这里句柄用于表示IO资源,即文件描述符。句柄集管理众多句柄,监听这些句柄的IO事件,将就绪事件通知领导者线程
线程集:管理所有工作线程,负责各线程之间的同步,以及新领导者线程的推选……
事件处理器:事件处理器需要绑定到句柄上。当句柄中有事件发生时,领导者线程就执行与句柄绑定的事件处理器(中的回调函数)……
优点:由于领导者线程自己监听IO,事件并处理客户请求
(既不是reactor也不是proactor),领导者/追随者模式不需要在线程之间传递额外的数据,也不需同步线程对请求队列的访问(没有队列…)
缺点:仅支持一个事件源集合,无法让每个工作线程独立地管理多个客户连接(如何理解?)
8.6 有限状态机
- 概述
有限状态机可作为逻辑单元内部的一种高效编程方法
和编译原理中的有穷自动机(DFA/NFA)
本质上是一个东西 => 更多内容参考《龙书》 - 状态独立的有限状态机
这类状态机的每个状态相互独立,即状态之间没有相互转移STATE_MACHINE(Package _pack){ PackageType _type=_pack.GetType(); switch(_type){ case type_A: process_package_A(_pack); break; case type_B: process_package_B(_pack); break; } }
- 带状态转移的有限状态机
State cur_state=type_A; while(cur_state != type_C){ Package _pack=getNewPackage(); switch(cur_state){ case type_A: process_package_state_A(_pack); cur_state=type_B; break; case type_B: process_package_state_B(_pack); cur_state=type_C; break; } }
- 应用实例
HTTP请求的读取和解析……p137
8.7 提高服务器性能的其他建议
- 池
静态分配:这类资源在服务器启动之初就完全创建好并初始化;
需要时直接从池中获取,无需动态分配……
常见的有:内存池、进程池、线程池、连接池…… - 数据复制
应该避免不必要的数据……
1.尽量避免用户代码和内核之间的数据复制
=> 如果内核可以直接处理从socket或文件读入的数据,则引用程序没有必要复制到应用程序缓冲区处理……
2.用户代码内部(不访问内核)的数据复制也是应该避免的 => 当两个进程间传递数据时,应该尽量使用共享内存…… - 上下文切换
1.进程/线程切换将占用大量的CPU时间,为每个客户连接都创建一个工作线程是不可取的;
2.当线程数量不大于CPU数目时,上下文切换就不是问题了…… - 锁
锁通常被认为是导致服务器效率低下的一个因素,所以应该尽量避免使用锁;
如果必须使用锁,则可以考虑减小锁的粒度,比如使用读写锁
第九章 I/O复用
- 写在前面
IO复用虽然能同时监听多个文件描述符,但是select/poll/epoll本身是阻塞的
;
多个描述符同时就绪时,通常只能按顺序依次处理每个描述符,从而使得服务器看起来像串行工作
;
所以,要实现并发,应该使用多进程/多线程手段……
9.1 select系统调用
-
select API
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
nfds:被监听的文件描述符总数;
readfds、writefds、exceptfds
:读/写/异常等事件对应的描述符集合,内核将修改他们来通知应用程序哪些描述符已就绪
(若同一个描述符出现在这三个集合中,则表示同时监听这个描述符上的读/写/异常事件);
select调用成功时返回就绪(读/写/异常)描述符的总数
;
如果在select调用期间,程序接收到异常,则select立即返回-1
,并设置errno为EINTR
补充:fd_set#define __FD_SETSIZE 1024 typedef long int __fd_mask; #define __NFDBITS (8*(int)sizeof(__fd_mask)) typedef struct{ __fd_mask __fds_bits[__FD_SETSIZE /__NFDBITS]; } fd_set; // 通过宏来访问fd_set FD_ZERO(fd_set* fdset); // 清除fdset的所有bits FD_SET(int fd, fd_set* fdset); // 设置fdset的bit fd FD_CLR(int fd, fd_set* fdset); // 清除fdset的bit fd int FD_ISSET(int fd, fd_set* fdset); // 测试fdset的bit fd是否被设置
可见,
fd_set结构体仅包含一个整型数组,该数组的每个bit标记一个文件描述符
=> select能监听的描述符数量有限(默认1024);
1024是因为,通常进程默认的最大描述符数量就是1024
;
此外,每次调用select,都必须重新设置fd_set
,应为上一次调用时内核可能已经修改了该fd_set; -
文件描述符就绪条件(重要)
socket可读条件
1.socket内核接收缓冲区中的字节数大于等于其低水位标记SO_RCVLOWAT
;
2.socket通信的对方关闭连接;
3.监听socket上有新的连接请求
;
4.socket上有未处理错误
socket可写条件
1.socket内核发送缓冲区中的可用字节数大于等于其低水位标记SO_SNDLOWAT
;
2.socket使用非阻塞connect连接成功或失败(超时)之后
;
3.socket上有未处理的错误 -
处理带外数据
有带外数据时,描述符上将会产生异常事件……代码示意如下:socket(); bind(); listen(); int connfd=accept(....); ..... while(1){ FD_SET(connfd,&read_fds); // 设置fdset,让内核监听这个描述符上的读事件 FD_SET(connfd,&exception_fds); ret=select(connfd+1,&read_fds,&NULL,&exception_fds,NULL); if(FD_ISSET(connfd,&read_fdst)){ // 普通可读事件 ....... } else if(FD_ISSET(connfd,&exception_fds)){ // 异常事件,接收带外数据..... ....... } }
详见p149……
9.2 poll系统调用
- poll API
int poll(struct pollfd fds, nfds_t nfds, int timeout); struct pollfd{ int fd; // 文件描述符 short events; // 注册的事件 short revents; // 实际发生的事件,由内核填充 };
fds
:epollfd结构体数组,它指定我们感兴趣的描述符上发生的事件(读/写/异常以及其他事件)
nfds:指定被监听事件集合fds的大小;
与select的区别:poll集合的大小可变长;poll支持更多的事件(select只支持读/写/异常)
9.3 epoll系统调用系列
-
内核事件表
epoll将用户关心的文件描述符上的事件放在内核的一个事件表中,从而无需像select、poll那样每次调用都传入描述符集和事件集
;
这个事件表由epoll_create()创建,由epoll_ctl()修改int epoll_create(int size); // size指定事件表的大小 int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event); struct epoll_event{ __uint32_t events; // epoll事件类型 epoll_data_t dta; // 用户数据 }; struct union epoll_data{ // 以下字段只能使用一个,因为是union void* ptr; // 可以用于指向与fd相关的用户数据 int fd; // 最常用,用于指定事件属于哪个描述符 uint32_t u32; uint64_t u64; }epoll_data_t;
epoll_ctl(…):
op指定操作类型(ADD、MOD、DEL),操作的对象就是参数fd和event;
event指定具体事件,它是结构体指针(不是数组),指定一个事件;
调用举例:通过add操作类型,向epfd注册fd上的event
补充:epoll支持的事件类型与poll基本相同
;但是epoll还有两个额外的事件类型EPOLLET和EPOLLONESHOT
-
epoll_wait函数
int epoll_wait(int epfd, struct epoll_event* envents, int maxevents, int timeout);
epfd:内核事件表,其中注册了感兴趣的描述符及事件;
events:函数输出,仅存储检测到的就绪事件
(注意与select、poll的区别);
返回值:就绪的描述符个数 -
LT和ET模式
epoll对描述符的操作有两种模式:LT(level trigger)、ET(edge trigger)
LT是默认的工作模式,此时epoll相当于一个效率较高的poll
;
LT模式:事件即使没有立即处理,下次epoll_wait返回时,还会通知上次没有处理的事件
ET模式:如果没有立即处理事件,下次epolll_wait返回时,将不再通知上次未处理的事件…… => 效率比LT更高
参考代码:9-3mtlt.cpp -
EPOLLONESHOT事件
对于注册了EPOLLONESHOT事件的描述符,操作系统最多触发其上注册的一个读/写/异常事件,且只触发一次 => 从而避免多个线程同时处理一个socket
;但是一旦正在处理的线程执行完毕,就得立即重置此事件
可见,对于注册了EPOLLONESHOT的描述符,同一时刻只有一个线程在为它服务,这就保证了连接的完整性,避免了很多可能的静态条件
参考代码:9-4oneshot.cpp
9.4 三组I/O复用函数的比较
- 对比
- 补充:实现原理上的差异
select和poll都是采用轮询的实现方式
:即系统调用在阻塞用户程序的时候,内核需要不断扫描整个注册文件描述符集合,并将其中就绪的描述符返回给用户 => 内核检测就绪事件的复杂度O(N)
epoll_wait采用回调的实现方式
:当有描述符就绪时,将自动触发回调(而不需要内核去轮询各描述符是否就绪),回调函数将就绪的描述符插入内核就绪事件队列,然后在适当的时机返回给用户程序
注意:当活动连接较多时,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发得过于频繁 =>epoll_wait适用于连接数量多,但是活动连接少的情况
9.5 I/0复用的高级应用一:非阻塞connect
- 概述
如果对非阻塞的socket调用connect,连接多半不能立即建立而且立即返回,此时connect会设置error为EINPROGRESS;
=> 这种情况下,可以调用select、poll等函数来监听这个连接失败的socket上的可写事件,等IO复用返回时,检测这个socket上的错误码,若为0则表示此时建立了连接,于是可以通过它向服务端发送数据
……
通过这种非阻塞connect方式,实现同时发起多个连接并一起等待
- 代码示例
9-5unblockconnect.cpp
9.6 I/O复用的高级应用二:聊天室程序
- 概述
主题:通过IO复用同时处理网络连接和用户输入
常见应用:聊天室程序、SSH => 本节展示通过IO复用实现聊天室程序 - 客户端
实现:客户端使用poll同时监听用户输入和网络连接
,并使用splice直接将用户输入零拷贝到网络连接
代码示例:9-6mytalk_client.cpp - 服务端
实现:服务端使用poll同时监听连接socket和监听socket
,当收到客户端的数据后,向所有其他客户端发送该数据(详见参考代码,很有技巧!!!)……
示例代码:9-7mytalk_server.cpp
9.7 IO复用的高级引用三:同时处理TCP和UDP服务
- 概述
1. 一个socket只能监听一个端口/绑定(bind调用),如果程序要同时监听多个端口,就得创建多个socket并绑定到不同的端口
=> 可用I/O多路复用管理此时的多个监听socket
2.即使是同一个端口,如果要同时处理该服务器上的TCP和UDP,则也得创建两个socket并绑定到同一个端口
(因为socket的数据流不同……) - 示例代码
此代码展示了epoll的一个回射服务器,同时处理TCP和UDP,重要参考!!!
9-8multi_port.cpp
9.8 超级服务xinted
- xinted超级服务介绍
1.xinted用来管理多种轻量级Internet服务,它是运行在后台的守护进程,且同时监听了多个端口(即子服务的端口),简单来说它是对这些子服务的包装
;
2. 对于某些内部服务,xinted进程直接进行处理,比如:日期服务daytime、回射服务echo等;
3. 还有某些服务需要调用外部服务程序来处理,xinted通过fork和exec来加载运行这些子服务,比如:telnet、ftp
等;
4. 具体可以使用xinetd的服务在/etc/services文件中指出 - xinted工作流程
1.xinted负责select 所有子服务的监听socket,然后将连接socket交给子进程处理
!!!
2.子进程的stdin、stdout被重定向,从而将连接socket视为其标准输入/输出
第十章 信号
10.1 Linux信号概述
- 信号与中断的区别
中断:可分为硬件中断
(外部中断)和软件中断
(内部中断/异常),常见的软件中断有除零错误、int指令等……
信号:是在软件层次上对中断/异常机制的一种模拟,但是信号!=中断/异常
信号的实现方式:信号一共有60个左右,进程控制块中有一个字段signal
,要给该进程发送信号时,就将signal对应的bit置为1……
区别
1. 中断/异常是直接发送给内核的,不需要区分进程;而信号是发送给进程的;
2. 中断/异常处理程序在内核态执行;而信号处理程序在用户态执行;
3.某些中断/异常发生时,也会给进程发生信号通知这一事件
。比如除法零错误、按下ctrl+c、定时器到时,当前进程也会收到信号;但是信号!=中断,信号处理程序!=中断处理程序
…… - 产生信号的时机
1. 用户输入某些终端字符时,对应的前台进程会收到信号;
2. 系统异常,比如浮点异常或者非法内存段访问等(既发生中断,也发送信号);
3. 系统状态变化,比如定时器时间到(既发生中断,也发送信号);
4. 运行kill命令或调用kill函数 - 发送信号
int kill(pid_t pid, int sig);
- 信号处理方式
信号的处理方式有:忽略、终止、捕获
信号处理函数:
除了自定义信号处理函数外,还可以选择忽略(SIG_IGN)信号、使用默认方式(SIG_DFL)处理信号typedef void (*__sighandleer_t)(int);
注意:为了避免一些竞态条件,信号在处理期间,系统不会再次触发它
- Linux标准信号
大概64种,这里不详述,自行查找相关资料…… - 中断系统调用
如果程序在执行处于阻塞状态的系统调用时接收到信号,则默认情况下系统调用将被中断
(系统调用返回用户态时检查到信号,转而执行信号处理程序?)
10.2 信号函数
- signal系统调用
sig:信号类型;_sighandler_t signal(int sig, _sighandler_t _handler);
_handler:_sighandler_t 类型的函数指针,用于指定型号sig的处理函数;
成功时返回一个函数指针,该函数指针的类型也是_sighandler_t,它就是调用signal时传入的函数指针;
失败时返回SIG_ERR,并设置errno - sigaction系统调用
它是设置信号处理函数的更健壮的接口
sigaction函数中:int sigaction(int sig, const struct sigaction* act, struct sigaction* oact); struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); // 此字段已不推荐使用... }
sig:指定信号;
act:指定新的信号处理方式;
oact:输出旧的信号处理方式;
sigaction结构体中:
sa_handler:指定信号处理函数;
sa_mask:指定哪些信号不能发送给本进程…… =>sigprocmask()系统调用也能设置信号掩码
10.3 信号集
-
信号集函数
typedef struct{ unsigned long int __val[SIGSET_NWORDS]; }sigset_t; // 信号集的操作函数 int sigemptyset(sigset_t* _set); // 清空信号集 int sigfillset(sigset_t* _set); // 设置信号集中所有信号 int sigaddset(sigset_t* _set,int _signo); // 将信号_signo添加至_set中 int sigdelset(sigset_t* _set, int _signo); // 将信号_signo从信号集删除 int sigismember(const sigset_t* _set, int _signo); // 测试_signo是否在集合中
可见
信号集sigset_t类似于文件描述符集fd_set
…… -
进程信号掩码
信号掩码:信号掩码集中的信号将不会发送给本进程
int sigprocmask(int _how, const sigset_t* _set, sigset_t* _oset);
_set:新设置的信号掩码;
_oset:原先的信号掩码;
_how:以何种方式设置;
当然,sigaction()系统调用也能设置信号掩码
(见10.2) -
被挂起的信号
被挂起的信号:如果给进程发送一个被屏蔽的信号(掩码对应的信号),操作系统会将这个信号挂起,进程将不能收到这个信号int sigpending(sigset_t* set);
获取被挂起的信号……
10.4 统一事件源
- 概述
统一事件源:所谓统一事件源是指让信号和其他I/O事件一样,被统一处理
实现方式:将信号的处理逻辑放到主循环中 => 信号处理函数不进行真正的处理,它只是将信号写入管道的一端,主循环检测到管道另一端的可读事件时,再真正处理该事件;这样,信号对主循环而言就是一个IO,信号和其他IO一样被统一处理…… - 示例代码
10-1unievent.cpp => 重要,好好理解!
10.5 网络编程相关信号
- SIGHUP
触发时机:通常是在挂起进程的控制终端时;
应用:对于没有控制终端的网络后台程序而言,通常使用SIGHUP信号来强制服务器重读配置文件(从而不用重启进程)…… - SIGPIPE
往一个读端关闭的管道或者socket连接中写数据将引发SIGPIPE
。这个信号的默认处理方式是结束进程 => 所以通常需要捕获/忽略这个信号,从而避免进程退出
判断读端是否已经关闭:1.可通过send函数反馈的errno判断; 2.也可通过IO复用来检测…… - SIGURG
检测带外数据到达的两种方法
1.I/O复用:select等系统调用收到带外数据时,会报告socket上的异常事件;
2.SIGURG信号:有带外数据时,SIGURG信号会发送给对应进程……