1-4章又介绍了TCP/IP协议

TCP/IP协议簇

上面这图很具有误导性,比如ICMP是作为数据封装到IP内,但把他们画到同一层。ARP是作为数据封装到数据链路(比如以太网帧)内,但把他们画到同一层

TCP: 可靠的、面向连接的、基于流的。

UDP: 不可靠、无连接、基于数据报的。

封装&分用:分用指的解析本层书籍,将处理后的数据往上层传。

复位报文段

产生复位报文段的三种情况:

访问不存在的端口

访问不存在的端口或者该端口处于TIME_WAIT状态,客户端会收到复位报文段。

异常终止连接

TCP提供了异常终止一个连接的方式,即向对方发送一个复位报文段。对方收到后,会丢弃所有排队等待发送的数据。

处理半打开连接

比如服务端和客户端已经连接上,但是服务端网线被拔了,重新接上后。只有客户端维护着连接,当他往管道写入数据时,会受到一个复位报文段。

TCP交互数据流

TCP连接的应用数据分为两类:交互数据和成块数据

比如通telnet访问服务器,会经过如下过程

每键入一个字符,会将该字符用TCP包发给服务器,服务器回发了确认消息以及数据,客户端立刻回发确认消息。

这可能导致网络上过多的数据包,可以通过Nagle算法解决:即限制通信双方只能有一个未被确认的的报文段(即,收到对方的确认前不能发送新数据)

TCP成块数据流

带外数据

用于通告对方本端发生的重要事件。

TCP、UDP都没有真正的带外数据,不过TCP利用其头部中的紧急指针和紧急指针标志为应用程序提供了一种紧急方式。

发送方将带外数据写入到发送缓冲区后,会设置紧急指针和紧急指针标志。

TCP/IP通信案例

Linux网络编程基础API

socket地址API

主机字节序和网络字节序

大部分PC使用(小端)主机字节序,Java虚拟机使用大端字节序。为了避免在建立连接时再沟通双方字节序,所以干脆在传输过程中都使用大端(网络)字节序。

htonl, htons, ntohl, ntohs是常用的转换方式。

通用socket地址

表示socket地址的是结构体sockaddr,

1
2
3
4
struct sockaddr{
sa_family_t sa_family;
char sa_data[14];
}
专用socket地址

所有专用socket地址类型的变量在使用时需要转化为通用socket地址类型sockaddr(强制转换)。

IP地址转换函数

inet_addr用于将点分十进制字符串IPv4地址转换为字节序整数表示的IPv4地址。

inet_aton也是完成这个功能,但他将转换结果保存到参数inp指向的结构中。

inet_ntoa将网络字节序整数转化为点分十进制表示的IPv4地址,但该函数内部用一个静态变量存储转换结果,他是不可重入的。

上面的三个函数只能用于IPv4,下面的两个函数可以指定协议簇,适用于IPv4和IPv6.

创建socket

int socket(int domain, int type, int protocol)

  • domain参数告诉系统使用哪个底层协议簇,比如PF_INET, PF_INET6
  • type参数指定了服务类型,SOCK_STREAM表示使用TCP。(从内核2.6.17开始,可以将SOCK_STREAM和两个标志相与后传给函数,这两个标志SOCK_NONBLOCK和SOCK_CLOEXEC分别表示socket是非阻塞的以及创建子进程时在子进程中关闭该socket)
  • protocol参数通常设为0

成功返回socket文件描述符,失败返回-1并设置errno。

命名socket

创建socket后,还需要为其指定某个具体的socket地址,这称为socket命名。int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen)

bind将my_addr指定的sockat地址分配给未命名的sockfd文件描述符。成功返回0,失败返回-1并设置errno,常见的两个errno:

  • EACCES,如果绑定的地址是知名服务端口,bind将返回这个错误。
  • EADDRINUSE,被绑定的地址正在使用中,比如将socket绑定到一个TIME_WAIT状态的socket地址。

监听socket

int listen(int sockfd, int backlog),

backlog参数指定处于ESTABLISHED的socket的上限,典型值为5。处于半连接状态SYN_RCVD的上限由内核参数配置。

接受连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)

addr用来获取远端socket地址。accept成功时返回新的连接socket,否则返回-1并设置errno。

发起连接

int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen),

关闭连接

int close(int fd)

它会将该fd的引用计数减一,当fd的引用计数变为0时,才会关闭连接。如果要立刻终止连接,可以使用shutdown系统调用

int shutdown(int sockfd, int howto)howto有三种参数

  • SHUT_RD,关闭读,应用程序不能读该sockfd,并清空读缓冲区。
  • SHUT_WR,关闭写,应用程序不能写该sockfd,并清空写缓冲区。
  • SHUTRDWR,关闭读写

数据读写

TCP数据读写

通用的文件读写操作readwrite适用于socket,但socket编程接口提供了几个专门用于数据读写的数据调用,他们增强了对数据读写的控制,如:

1
2
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • recv: flags通常设为0即可。recv成功返回数据长度,出错则返回-1并设置errno,对方已经关闭连接则返回0。(如果还没收到对方的数据,则不返回)
  • send,成功返回实际写入数据的长度,失败返回-1并设置errno。

flags参数提供了额外的控制。

flags参数只对当此send和recv调用生效,可以通过setsockopt来永久的修改socket的某些属性。

针对含有带外数据TCP数据读取,正常数据的接收会被带外数据截断,比如服务器发送123abc123,其中abc是带外数据(设置flags为MSG_OOB),客户端通过一次recv是没法读取全部数据的,通过recv()读取123ab,然后通过recv(MSG_OOB)读取c, 通过recv读取123

UDP数据读写
1
2
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 flags, const struct sockaddr* dest_addr, socklen_t addrlen);

因为UDP是无连接的,并不会将sockfd绑定到一个固定的地址上,所以每次发送数据时都需要指定从哪个地址发送数据,

通用数据读写函数

socket编程接口提供了一对通用的数据读写系统调用。它可以用与TCP和UDP的数据读取。

带外标记

地址信息函数

获取sockfd对应的socket地址。

socket选项

如果说fcntl是控制文件描述符属性的通用POSIX方法,那么如下的两个系统调用则是设置socket文件描述符属性的方法:

不同的level指定了要对哪些协议进行操作:

  • 对服务器而言有些选项只能在listen系统调用前生效,因为比如最大报文段选项只能在TCP同步报文段设置,执行listen系统调用后,可能已经接受了客户端的连接,这时已经没法更改最大报文段选项了。通常accept返回的socket会自动继承监听socket的选项,包括SO_DEBUG、SO_DONTROUTE等。
  • 对于客户端而言,这些选项应该在connect之前设置。
SO_REUSEADDR选项

通过将该选项设置为1,可以直接使用处于TIME_WAIT状态的socket地址,也可以通过修改内核参数来快速回收被关闭的socket。

SO_RCVBUF和SO_SNDBUF

设置TCP发送和接收缓冲区的大小。系统会将该参数加倍,并且不得小于某个最小值。

SO_REVLOWAT和SO_SNDLOWAT选项

通常低水位标记都为1字节。当可读数据大于低水位标记时,会通知应用程序读数据,当空闲空间大于低水位时,会通知往socket写数据。

SO_LINGER选项

用于控制close系统调用在关闭TCP连接时的行为。

  • 默认情况下,当通过close关闭连接时,会立刻返回,并将TCP发送缓冲区残留的数据发送给对方。

  • 当设置了SO_LINGER选项时,需要传递一个linger结构体

    1
    2
    3
    4
    struct linger{
    int l_onoff; //开启还是关闭
    int l_linger; //滞留时间
    }
    • l_onoff等于0时,SO_LINGER选项不起作用,close仍然使用默认行为来关闭socket。
    • l_onoff不为0,l_linger等于0,这时close系统调用立刻返回,TCP模块将丢弃残留的数据,同时给对方发送一个复位报文段。
    • l_onoff不为0,l_linger大于0。
      • 对于阻塞socket,close将等待一段长为l_linger的时间,直到发送完所有数据并收到确认,没有发送完以及得到确认会返回-1设置errno为EWOULDBLOCK.
      • 对于非阻塞socket,close会立刻返回,此时需要根据返回值和errno来判断数据是否已经发送完毕。

网络信息API

可以用主机名来代表ip地址,也可以用服务名称来代表端口号。

gethostbyname和gethostbyaddr

可以通过名字和地址获取主机的完整信息。(先从/etc/hosts中找,再从DNS找)

getservbyname和getservbyport

可以通过名字和端口号获取服务的完整信息。(从/etc/services读取服务信息)

这四个函数都不可重入,不是线程安全的。函数名后加_r代表可重入的。

getaddrinfo

可以根据主机名获取IP地址,也可以通过服务名获得端口号,是否可重入取决于其内部的gethostbyname和getservbyname是否是可重入的。

1
int getaddrinfo(const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result);
  • hostname可以是主机名也可以是IP地址
  • service可以是服务名也可以是端口号
  • hints可以为NULL
  • results指向一个addrinfo类型的链表
getnameinfo

获取主机名和服务名

高级IO函数

Linux提供了很多高级的IO函数,他们在特定的情况下性能很优异,这些函数分为三类:

  • 创建文件描述符的函数,如pipe、dup/dup2函数
  • 读写数据的函数,如readv/writev、sendfile、mmap/munmap、splice和tee函数
  • 用于控制I/O行为和属性的函数,如fcntl函数

PIPE函数

pipe函数用于创建一个管道,以实现进程间通信。

1
int pipe(int fd[2]);
  • 通过pipe函数创建的两个文件描述符fd[0]和fd[1]分别构成管道的两端,只能从fd[1]写,只能从fd[0]读,要实现双向传输需要两个管道。
  • 默认情况下管道是阻塞的。
  • 如果写端的引用计数为0,则读端的read操作返回0,代表读到了文件结束标记。如果读端的引用计数为0,写端的写操作会失败,出发SIGPIPE信号。

还有一个socketpair函数,它可以方面的创建双向管道

dup函数和dup2函数

1
2
3
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

这两个函数可以复制一个文件描述符,两者的区别在于dup返回的文件描述符返回的是最小的未被使用的描述符,dup2返回的是指定的描述符newfd。

通过dup和dup2创建的文件描述符并不继承原文件描述符的属性,比如close-on-exec和non-blocking等。

readv函数和writev函数

readv将数据从文件描述符读到分散的内存块中,writev将多块分散的内存数据写入到文件描述符中。这两者相当于简化版的sendmsg和recvmsg函数。

如果想要向对方发送多个缓冲区上的数据,则没有必要将他们拼接到一个缓冲区上在发送,而是可以直接通过writev来发送。

sendfile函数

sendfile可以直接在两个文件描述符之间传递数据(完全在内核空间操作),从而避免了在内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,称为零拷贝。in_fd必须是一个文件描述符(因为sendfile底层需要使用mmap将in_fd进行映射),out_fd在Linux2.6.33后可以是任何文件。

ssize_t sendfile(int out_fd, int in_fd, off_t* offset, ssize_t count)

mmap和munmap函数

mmap可以申请一段内存,用于进程间通信的,也可以将文件映射到内存中。

  • start指定了映射到虚拟内存中的地址,可以为空
  • length指定了内存段的长度
  • prot参数用来设置内存段的访问权限,可读可写可执行
  • flags指定一些标志,比如是否在进程间共享,是否是从文件映射来的。

成功返回指向内存区域的指针,失败返回MAP_FAILED((void*)-1)

splice函数

用于在两个文件描述符之间移动数据,也是零拷贝数据。(两个文件描述符中,有一个必须是管道)

1
2
#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);

tee函数

在两个管道文件描述符之间复制数据,他不消耗数据。

1
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

fcntl函数

fcntl和ioctl都可以对文件描述符进行各种操作,ioctl比fcntl能执行更多的控制,对于控制文件描述符常用的属性,fcntl函数是POSIX规定的首选方法。

1
int fcntl(int fd, int cmd, ... /* arg */ );

fcntl常用来将文件描述符设置为非阻塞的。

Linux服务器程序规范

  • 服务器程序以守护进程形式存在,父进程一般是init进程
  • 服务器程序一般有日志系统,可以输出到文件,一般在/var/log下,有的服务器还能输出日志到专门的UDP服务器。
  • Linux服务器程序一般是以非root身份运行的,如mmysqld、httpd、syslogd,分别有自己的运行账户mysql、apache、syslog。
  • 可以通过命令行选项和配置文件对服务器程序进行配置,大部分配置文件存放在/etc目录下
  • 服务器启动时会生成一个PID文件存入/var/run目录下,以记录该程序的PID,以便于其他程序查询。

日志

Linux系统日志

Linux一般使用rsyslogd(syslogd)这个守护进程来处理日志。该进程可以同时接收来自用户进程和内核的日志。

  • 用户进程是通过调用syslog函数来生成系统日志,该函数将日志输出到UNIX本地域socket类型的/dev/log上。rsyslogd则监听该文件以获取用户进程的输出。
  • 内核日志是通过printk等函数打印到内核的环形缓存中,环形缓存的内容直接映射到/proc/kmsg文件中,rsyslogd通过读取该文件获取内核日志。

rsyslogd收到用户或内核的日志后,会根据配置文件将日志输出到日志文件。

syslog函数
1
2
#include <syslog.h>
void syslog(int priority, const char *format, ...);

该函数使用可变参数来结构化输出。

  • priority,是设施值和日志级别的按位与,设施值的默认值是LOG_USER, 日志级别有如下几类:

可以使用下面的函数开启logger并设置syslog的默认输出方式:

1
void openlog(const char *ident, int option, int facility);
  • ident指定的字符串被存放到日志消息的时间之后,它通常被设置为程序的名字

  • option参数对后序syslog调用的行为进行配置,它可以如下值的组合

  • facility可以修改syslog函数中的默认设施值,

通过如下函数设置日志掩码:

1
int setlogmask(int maskpri);

用户信息

很多服务器进程需要以root身份启动,但实际运行时,需要切换为普通用户身份运行,以保证安全性。

UID、EUID、GID和EGID

uid表示的是进程的创建者,euid表示的是进程对资源的实际访问权限。

  • 通常一个进程能访问什么资源,取决于创建它的进程(uid,从用户的角度上看的)的权限。
  • 使用euid后,一个进程能访问什么资源,取决于该程序的所有者(从文件系统的层面上看的)的权限

比如su程序的所有者是root,并且他设置了set-user-id标志,那么通过非root用户执行该程序时,有效用户是root。有效用户为root的进程称为特权进程。

1
2
3
4
5
6
7
8
9
10
11
#include<unistd.h>
#include<stdio.h>
int main(void){
uid_t uid = getuid();
uid_t euid = geteuid();
printf("userid is %d, effective userid is %\n", uid, euid);
return 0;
}
(yolo) tangjie.zhang@moveai415:chapter7$ g++ -o uid_euid uid_euid.cpp
(yolo) tangjie.zhang@moveai415:chapter7$ ./uid_euid //1037对应我的用户的id,euid暂时没有设置,可以通过chmod +s uid_euid配置
userid is 1037, effective userid is %
切换用户

通过setgid和setuid实现。

进程间关系

进程组

Linux下用户有用户id和用户组id

Linux下的文件对应一个所有者用户、同组用户和其他用户

Linux下的每个进程都隶属一个进程组,都有一个PID和PGID(进程组ID)。每个进程组都有一个首领进程,他的PGID和PID是相同的,进程组会一直存在直到所有进程都退出。

1
int setpgid(pid_t pid, pid_t pgid)

该函数用于将PID为pid的进程的PGID设置为pgid。

  • 如果pid和pgid相同,则将该进程从原进程组分离出来,并设置为进程组首领。
  • pid为0,则默认将当前进程的PGID设置为pgid。
  • pgid为0,则使用pid作为目标PGID。

一个进程只能设置自己或者其子进程PGID,

会话

一些有关联的进程将形成一个会话(session),下面的函数用于创建一个会话。

1
pid_t setsid(void);

该函数不能有进程组的首领进程调用,当非首领进程调用该函数时,会产生创建一个新会话,并且:

  • 调用进程成为会话的首领,此时该进程是新会话的唯一成员
  • 新建一个进程组,调用进程成为该组的首领

Linux进程没有提供所谓会话ID的概念,Linux认为它等于会话首领所在的进程组的PGID。提供了如下的方式读取:

1
pid_t getsid(pid_t pid);
用ps命令查看进程关系
1
2
3
4
5
$ ps -o pid,ppid,pgid,sid,comm | less
PID PPID PGID SID COMMAND
3535939 3535938 3535939 3535939 bash
3537268 3535939 3537268 3535939 ps
3537269 3535939 3537268 3535939 less

这三个命令创建了一个会话和两个进程组,bash是会话的首领也是进程组3535938的首领,ps命令是进程组3537268的首领。

系统资源限制

Linux上运行的程序会受到系统资源限制的影响,比如物理设备限制(CPU、内存),Linux系统资源限制可以通过如下的函数读取和设置:

1
2
3
4
5
6
7
#include<sys/resource.h>
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *lim);
struct rlimit{
rlim_t rlim_cur; //rlim_t是一个整数类型
rlim_t rlim_max;
}

rlim_cur是指定资源的软限制,而rlim_max是硬限制,(CPU时间超过软限制时,会由系统向进程发送SIGXCPU, 文件尺寸大于软限制时,会由系统向进程发送SIGXFSZ信号)

改变工作目录和根目录

当我们使用相对路径时,系统会以工作目录为起点来查找文件。

使用绝对路径时,使用根目录为起点来查找文件。如果使用chroot /usr/local,那么这个进程的根目录就会变为/usr/local,此时,即使执行ls /,看到的也是/usr/local目录下的内容。

获取进程当前工作目录和改变进程工作目录的函数分别是:

1
2
3
#include<unistd.h>
char *getcwd(char * buf, size_t size);
int chdir(const char * path);

改变进程根目录的函数是chroot(只有特权进程才能改变根目录):

1
int chroot(const char* path);

服务器程序后台化

可以使用daemon库函数来实现,也可以亲手实现(创建子进程,关闭父进程,使其父进程变为init,更改文件权限掩码为0,设定本进程为进程组的首领,切换工作目录,关闭输入输出,把输入输出重定向到dev/null

关闭标准输出后,如果程序还在使用printf打印信息,这些信息会被放到缓冲区,所以建议使用/dev/null来让屏蔽输出。

高性能服务器程序框架

三个主要模块:

  • I/O处理单元:四种IO模型和两种事件处理模式
  • 逻辑单元:两种高效并发方式,以及高效的逻辑处理方式——有限状态机
  • 存储单元

服务器模型

C/S模型

如下,使用的是IO复用技术之一的select系统调用。

P2P

服务器编程框架

服务器的基本框架都是一致的,如下:

该图各个部分的功能如下:

  • IO处理单元负责管理客户连接,(数据收发,optional,取决于事件处理模式)
  • 逻辑单元通常是一个进程、线程、逻辑服务器
  • 网络存储单元

IO模型

socket默认是阻塞的,可以通过socket的第二个参数或fcntl系统调用来将其设置为非阻塞的。阻塞的概念适用于所有文件描述符,阻塞的文件描述符称为阻塞IO,否则称为非阻塞IO。

  • 如果是阻塞IO,当没有完成操作时,该进程会被阻塞,直到完成操作。可能被阻塞的系统调用包括accept、send、recv、connect。
  • 如果是非阻塞IO,不管事件有没有发生,系统调用都会立刻返回。如果时间没有立刻发生,系统调用会返回-1,这和出错的情况一样,所以还必须根据errno来区分这两者情况。
    • 对accept、send、recv而言,事件未发生时,errno被设置为EAGAIN或EWOULDBLOCK(期望阻塞)。
    • 对connect而言,时间未发生时,errno被设置为EINPROGRESS(正在处理)。

非阻塞IO必须和其他IO通知机制一起使用,比如IO复用和SIGIO信号

  • IO复用,应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数将其中就绪的事件通知应用程序。常用的IO复用函数包括select, poll, epoll_wait
  • SIGIO信号也可以用来报告IO事件,可以为一个文件指定一个进程。当文件内容改变时,会给对应进程发SIGIO、SIGURG信号。见6.8,10。

阻塞IO、IO复用、信号驱动IO都是同步IO模型。那什么是异步IO模型呢?

对于同步IO而言,需要等待操作完成(通过阻塞或轮询,epoll_wati也会阻塞),才会执行其他任务,整个IO过程由调用方管理。(比如recv,需要等待接收缓冲区非空,才会返回,并接收数据)

对于异步IO而言,不需要等待任何事件,立刻返回,具体操作由操作系统完成。

对异步IO而言,用户可以直接对IO执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及内核通知应用程序的方式。同步IO需要用户自行执行IO操作,而异步IO由内核来执行IO操作(数据在用户缓冲区与内核缓冲区的移动是靠内核完成的),并在完成时通知程序。

两种高效的事件处理模式

服务器程序需要处理三类事件:IO事件、信号和定时事件。

两种事件处理模型:Reactor和Proactor。通常用同步IO实现Reactor模式,而使用异步IO实现Proactor模式。(也可以使用同步IO实现Proactor模式)

Reactor模式

Reactor模式:主线程(IO处理单元)只负责监听文件描述符,有的话就通知工作线程(逻辑单元)。除此之外,主线程不做其他实质性的工作。接受连接、读写数据、处理客户请求均在工作线程完成。

使用同步IO模型(比如epoll_wait)实现的Reactor模式的工作流程如下:(可能阻塞的操作需要通过epoll_wait来统一监测,比如从socket读、写)

  • 主线程往epoll内核事件表注册socket上的读就绪事件
  • 主线程调用epoll_wait阻塞,等待socket上有数据可读。
  • socket上有数据可读时,epoll_wait会通知主线程,主线程将socket可读事件放入请求队列。
  • 睡眠在请求队列上的某个工作线程被唤醒,他从socket读取数据,并处理客户请求,生成结果。然后往epoll内核事件表注册该socket上的写就绪事件。
  • 主线程调用epoll_wait阻塞,等待对应socket上可以写数据。(有足够的缓冲区)
  • 当socket可写时,epoll_wait通知主线程,主线程将socket可写事件放入请求队列。
  • 睡眠的某个工作线程被唤醒,往socket上写入结果。

所有工作线程都会根据事件的类型来决定处理方式,是读、操作还是写。所以没必要区分读工作线程和写工作线程。

Proactor模式

Proactor模式:Proactor模式将所有IO操作交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

使用异步IO模型(aio_read和aio_write)实现的Proactor模式的工作流程是:

  • 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读完成后通知主线程的方式(比如信号)。
  • 主线程继续处理其他逻辑
  • 当socket上的数据被读到用户缓冲区时,内核向主线程发一个信号,通知数据可用。
  • 主线程使用预定义的信号处理函数(sighandler)选择一个工作线程来处理客户请求。工作线程执行完后,调用aio_write向内核注册socket上的写完成事件,并告诉内核用户缓冲区的位置,以及操作完成后如何通知主线程。
  • 主程序继续处理其他逻辑
  • 当数据被写到socket后,内核向应用程序发一个信号,以通知应用程序是否已经发送完毕。
  • 应用程序使用预定义好的信号处理函数来完成善后,

这里的epoll_wait只需要监听连接事件,而不用监听读写事件。

模拟Proactor模式

由主线程(代替内核)执行数据读写操作,然后向工作线程通知这一事件,这样工作线程也只需要对数据进行处理。

两种高效的并发模式

并发编程指IO处理单元和多个逻辑单元之间协调完成任务的方法。分为半同步/半异步,领导者/追随者模式。

半同步/半异步方式

这里的同步和IO模型中的同步和异步是不同的概念。

  • 在IO模型中,同步和异步区分的是内核向应用程序通知的是那种IO事件(是就绪事件还是完成事件),以及由谁来完成IO读写。
  • 在并发模式中,同步指的是完全按照代码序列的顺序运行,异步指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号。

按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。异步线程的执行效率高,但复杂。同步线程效率低,但简单。对应服务器,使用半同步/半异步模式实现。

逻辑单元部分是同步的,IO处理单元部分是异步的。(逻辑单元部分是按照代码顺序执行的,IO处理单元部分是根据各种事件运行的)

事件处理模式可以和IO模型接合起来:

半同步半反应堆模式(并发模式是半同步半异步,事件处理模式是reactor模式)

一种高效的半同步半异步模式:主线程只管理监听socket,工作线程管理连接socket。(这不就异步吗,为什么还要叫半同步,/晕晕)

领导者/追随者模式

多个工作线程轮流获取事件源集合,轮流监听。如果当前领导者进程检测到IO事件,首先要从线程池选择新的领导者线程,这个新的领导者线程等待IO事件,原来的领导者线程开始处理IO事件。

  • 句柄集,管理若干句柄(IO资源,即若干socket),领导者调用绑定到句柄上的事件处理器来处理事件

  • 线程集,所有工作线程(领导者+追随者线程)的管理者,负责线程同步以及新领导的推选。线程集中的线程必然处于如下状态之一:

    • Leader:线程处于领导者状态,在等待句柄集上的IO事件。
    • Processing:线程正在处理事件。领导者检测到IO事件后,可以转换到Processing态,并选新领导者;也可以指定其他Follower来处理事件。Processing态的线程处理完后,如果当前没有Leader,则自己当Leader。
    • Follower:可以调用线程集的join方法成为新Leader,也可以被Leader指定任务。
  • 事件处理器和具体的事件处理器

有限状态机——逻辑单元内部的高效编程方法

应用层协议比如HTTP头部包含数据包类型字段,可以将类型映射为逻辑单元的一种执行状态,比如GET,HEAD,POST。

  • 状态独立的FSM:不能在状态之间转换。
  • 带状态转移的FSM:可以在内部驱动下进行状态转移。

例如HTTP头部解析,读到空行代表头部结束,需要使用一个主状态机来判断头部是否结束,还需要使用一个从状态机对每个请求行进行解析。

  • 主状态机:包含两个状态,表示当前读取的是头部字段还是请求行
  • 从状态机:包含三个状态,表示当前读取的行正确、错误还是不完整
  • 服务器处理HTTP的结果包含如下几种:NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST, INTER_REQUEST, CLOSE_CONNECTION。

提高服务器性能的建议

直接从池中获取资源,比动态分配资源的速度要快很多。

常见的比如:

  • 内存池,用于socket的接收和发送缓存。
  • 进程/线程池,直接从进程池取得一个执行实体,而无需动态的调用fork。
  • 连接池,用于维护服务器和数据库程序的一组连接。
数据复制

gao高性能服务器应该避免不必要的数据复制,尤其是发生在内核和用户代码之间的复制。

如果没必要,则不需要将数据读到用户缓冲区。比如发送服务器上的文件时,可以直接通过sendfile发送,而不是先读到应用程序的缓存,再发送。

进程之间数据访问也应该被避免,当两个工作进程需要传递大量数据时,使用共享内存,而不是管道或消息队列。

上下文切换

不应使用过多的工作线程,避免太多的上下文切换。

避免锁,或者使用细粒度的锁,使用读写锁。

IO复用

IO复用使监听多个文件描述符成为了可能。

IO复用是串行的(不使用多进程等手段时),每次只能选择一个文件描述符再执行。

常见的包括select、poll和epoll。

select系统调用

1
2
3
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
  • nfds:代表被监听的文件描述符的总数,即为所有文件描述符的最大值加一(0-maxnum总共有maxnum+1个数)。

  • readfds、writefds、exceptfds参数分别指向可读可写和异常事件对应的文件描述符集合。fd_set是一个结构体,其中包含了一个整型数组,数组的每一位代表一个fd。

  • timeout用来设置select函数的超时时间,timeval结构体有两个值代表秒和微秒

    • 如果设为0,则立刻返回
    • 设为NULL,则一直阻塞,直到某个文件描述符就绪
    • 成功返回就绪文件描述符的总数
    • 给定时间内,没有文件描述符就绪,返回0
    • 失败时,返回-1并设置errno
    • 如果在等待期间,程序收到信号,select返回-1并设置errno为EINTR。
文件描述符就绪条件

在如下情况下,socket可读:(个人感觉,准确来说是通知应用程序该socket需要处理,而不是仅仅是可读)

  • socket内核缓冲区的字节数大于等于低水位标志SO_RECVLOWAT(即1),调用read后返回大于0的数。
  • socket的对方关闭连接,调用read后返回0。
  • 监听socket上有新的连接请求。(通知后,应用程序可以根据文件描述符判断是listenfd,然后用accept来处理新的连接请求,如果不是listenfd,则进行其他处理)
  • socket上有未处理的错误,使用getsocopt来读取和清除该错误。

在如下情况下,socket可写:

  • socket内核发送缓冲区可以字节数大于等于低水位标志SO_SNDLOWAT(即1)
  • socket的写操作被关闭,这是继续往socket写数据时会触发SIGPIPE信号。
  • socket使用非阻塞connect连接成功或者失败之后。
  • socket上有未处理的错误,使用getsocopt来读取和清除该错误。

服务端网线被拔了,重新接上后。只有客户端维护着连接,当他往管道写入数据时,会受到一个复位报文段。

select能处理的异常情况只有一种:socket上接收到带外数据。

处理带外数据

SELECT返回后,判断sockfd的读、写、异常位是否被置位,然后执行针对性的操作。

poll系统调用

poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,已测试是否有就绪者。

1
2
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds是一个pollfd结构类型的数组,它指定我们所有感兴趣的文件描述符上发生的可读可写异常事件

    1
    2
    3
    4
    5
    struct pollfd{
    int fd;
    short events;//注册的事件
    short revents;//实际发生的事件,由内核来写
    }
  • nfds指定第一个参数,数组的大小

  • timeout指定超时值。

epoll系列系统调用

内核事件表

epoll与select和poll不同的是,将事件表放到了内核中,而不是通过函数参数传入传出。所有epoll需要一个额外的文件描述符标识放到内核中的事件表。

创建内核事件表,获取文件描述符:

1
2
#include<sys/epoll.h>
int epoll_create(int size);//返回到数就是内核事件表对应的文件描述符

操作内核事件表:

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
  • fd指的是要操控的文件描述符

  • op有三种

    • EPOLL_CTL_ADD
    • EPOLL_CTL_MOD
    • EPOLL_CTL_DEL
  • event指定事件,他是epoll_event结构类型如下

    1
    2
    3
    4
    struct epoll_event{
    __uint32_t events;
    epoll_data_t data;
    }
    • 其中events成员描述事件类型,事件类型的种类和poll很相似。但多了两个:EPOLLET、EPOLLONESHOT。

    • epoll_data_t是一个union,如下(这些数据是epoll_wait返回时用的,返回的是epoll_event类型,通过这个类型的data的fd需要能够获得文件描述符)

      1
      2
      3
      4
      5
      6
      typedef union epoll_data{
      void *ptr;
      int fd;
      uint32_t u32;
      uint64_t u64;
      }epoll_data_t;
epoll_wati函数

他在一段超时时间内等待一组文件描述符上的事件,

1
2
3
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);

成功时返回文件描述符的个数,失败时返回-1并设置errno。

  • timeout
  • maxevents,必须大于0
  • events,检测到事件,就将事件放到第二个参数里面,个数由返回值指出。
LT和ET模式

epoll对文件描述符的操作有两种模式:LT和ET(LT是默认模式,ET更高效)。

具体代码可见数据以及3090上。

ET模式不能搭配阻塞IO使用。

因为当第一次收到数据时,ET模式的会循环使用recv读取数据,当读完数据时,也会使用recv继续读取,这时因为IO是阻塞的,那么程序就会阻塞在IO操作上。

相对的,使用非阻塞IO时,recv返回后,会根据errno来判断是继续读,还是暂停读。