《Linux高性能服务器编程》— API/一般框架/IO单元

第五章 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
    struct sockaddr_in{
    	sa_family_t sin_family;   // 地址族,填写AF_INET
    	u_int16_t sin_port;       // 端口号,要用大端表示!!!
    	struct in_addr sin_addr;  // IPv4地址,这个结构体其实只包含一个32位无符号整型
    }
    
    注意:所有专用socket地址只是为了用户方便,真正使用时仍然需要强制转换为通用的sockaddr
  • IP地址转换函数
    字符串地址 => 大端表示的网络地址:inet_addrinet_aton
    in_addr_t inet_addr(const char* strprt); 
    int inet_aton(const char* cp,struct in_addr* inp);
    
    大端表示的网络地址 => 字符串地址:inet_ntoa
    char* inet_ntoa(struct in_addr in);
    
    注意:1.inet_ntoa是不可重入的; 2.还有更新的转换函数:inet_ptoninet_ntop

5.2 创建socket

  • API
    int socket(int domain, int type,int protocol);
    
    domain:协议族,对于TCP/IPv4,通常是AF_INET
    type:服务类型,TCP是SOCK_STREAMUDP是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创建监听队列
    int listen(int socketfd, int backlog);
    
    sockfd:被监听的描述符;
    backlog:内核监听队列的最大长度(仅指完全连接状态的socket),典型值是5

5.5 接受连接

  • API
    accept只负责从监听队列取出连接,不管连接是处于什么状态!
    int accept(int sockfd, struct sockaddr* addr, socklen_t addrlen);
    
    sockfd:listen调用过的监听socket;
    addr:客户端连接的socket地址
    返回值:连接socket !!!

5.6 发起连接

  • API
    发起连接通常是由客户端调用:
    int connect(int sockfd,const struct sockaddr* serv_addr, socklen_t addrlen);
    
    返回:连接socket,客户端通过读写这个连接socket来与服务端通信!

5.7 关闭连接

  • close
    int close(int fd);只是将fd的引用计数减1,只有当fd减为0时才真正关闭连接
  • shutdown
    shutdown无论如何都能立即终止连接
    int shutdown(int sockfd, int howto);
    
    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信号
  • 如何检测带外数据在数据流中的位置
    int sockatmark(int sockfd);	
    
    若返回0,表示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
    int dup(int file_descriptor);
    
    返回一个新的文件描述符,与file_descriptor指向相同的文件/管道/网络连接;
    返回的文件描述符总是取当前系统可用的最小值;
  • dup2
    int dup2(int file_descriptor_one,int file_descriptor_two);
    
    返回一个新的文件描述符,与file_descriptor_one指向相同的文件/管道/网络连接;
    但是返回的fid不能小于file_descriptor_two
  • 补充
    作用:常用于将标准输入重定向到一个文件; 或者将标准输出重定向到一个网络连接,如CGI(实现方法就是先关闭标准输出,然后dup连接描述符,此时返回的fid为1……)
    注意:dup和dup2返回的文件描述符不继承原文件描述符的特性,比如non-blocking等

6.3 readv和writev函数

  • readv
    将数据从文件描述符读到分散的内存块中,即分散读
    ssize_t readv(int fd, const struct iovec* vector, int count);
    
    结构体iovec描述一块内存区域; 参数count是vector数组的长度;
    相当于简化版的recvmsg
  • writev
    将多块分散的内存数据一并写入文件描述符,即集中写
    ssize_t writev(int fd, const struct iovec* vector, int count);
    
    相当于简化版的sendmg;
    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.也可以将文件直接映射到其中
    void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
    
    start:用户指定的内存起始地址,设为NULL则自动分配;
    length:要分配的内存段长度;
    fd:这段内存要被映射的文件;
    offset:从文件的何处开始映射
  • munmap
    int munmap(void* start, size_t length);
    
    释放mmap分配的内存……

6.6 splice函数(零拷贝)

  • splice
    也是用于在两个文件描述符之间移动数据,同样是零拷贝
    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,否则off_in表示从文件的哪个偏移开始拷贝;
    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
    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的作用有效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 改变工作目录和根目录

  • 概述
    char* getcwd(char* buf, size_t size);	// 获取进程当前工作目录
    int chdir(const char* path);			// 将进程工作目录切换到path
    int chroot(const char* path);			// 将进程根目录切换到path
    
    1.chroot()并不改变进程的当前工作目录,所以调用chroot后仍然要使用chdir()来将工作目录切换到新的根目录
    2.改变进程根目录后,无法访问/dev等路径,因为这些路径并非出于新的根目录之下
    3. 不过切换根目录之前的文件描述符仍然能正常访问原来的文件/目录;
    4. 只有特权进程才能切换根目录

7.6 服务器程序后台化

  • 概述
    int daemon(int nochdir, int noclose);
    
    nochdir:用于指定是否改变当前工作目录。若为0,则将当前工作目录改为“/”;否则继续使用当前工作目录
    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);
    
  • 信号处理方式
    信号的处理方式有:忽略、终止、捕获
    信号处理函数:
    typedef void (*__sighandleer_t)(int);
    
    除了自定义信号处理函数外,还可以选择忽略(SIG_IGN)信号、使用默认方式(SIG_DFL)处理信号
    注意:为了避免一些竞态条件,信号在处理期间,系统不会再次触发它
  • Linux标准信号
    大概64种,这里不详述,自行查找相关资料……
  • 中断系统调用
    如果程序在执行处于阻塞状态的系统调用时接收到信号,则默认情况下系统调用将被中断(系统调用返回用户态时检查到信号,转而执行信号处理程序?)

10.2 信号函数

  • signal系统调用
    _sighandler_t signal(int sig, _sighandler_t _handler);
    
    sig:信号类型;
    _handler:_sighandler_t 类型的函数指针,用于指定型号sig的处理函数;
    成功时返回一个函数指针,该函数指针的类型也是_sighandler_t,它就是调用signal时传入的函数指针;
    失败时返回SIG_ERR,并设置errno
  • 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);		// 此字段已不推荐使用...
    }
    
    sigaction函数中
    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信号会发送给对应进程……
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值