github仓库:https://github.com/brucewayne9064/LinuxCpp_server

按照协议来分有TCP服务器和UDP服务器,按照处理方式分为循环服务器和并发服务器

  • 循环服务器

    服务器需要处理多个客户端,循环的同一时刻只能响应一个客户端请求。优点是简单,缺点是会造成其他客户端等待时间过长

  • 并发服务器

    利用多线程或者多进程,同一时刻可以响应多个客户端请求(为每个客户端创建一个任务)

服务器的设计模型有:

  • (分时)循环服务器
  • 多进程/多线程并发服务器
  • I/O复用并发服务器

小规模用循环服务器,大规模用并发服务器

一、I/O模型

1.1基本概念

(服务器模型和IO模型是两个概念)

  • IO:数据的读取写入/接收发送
  • 用户进程的完整IO:(1)用户空间《—》内核空间 (2)内核空间《—》设备空间(磁盘,网卡)
  • IO分类:内存IO,网络IO(本书),磁盘IO

网络IO的本质是对socket的读取,socket在Linux中被抽象为,IO可以理解为对流的操作,对于一次IO访问,数据会先被拷贝到OS的内核缓冲区,再从操作系统的内核缓冲区拷贝到应用程序的地址空间

网络应用需要处理的两大类问题:网络IO和数据计算,网络IO的瓶颈更大,所以更重要。

网络IO模型分类:

  • 异步IO(asynchronous IO)
  • 同步IO(synchronous IO):阻塞IO(blocking IO),非阻塞IO(non-blocking IO),多路复用IO(multiplexing IO),信号驱动IO(signal-driven IO)

1.2同步和异步(消息的通知机制)

对于一个线程的请求调用,如果需要等待最终结果,则为同步;如果在没有得到结果的情况下返回,则为异步。例如调用readfrom系统调用,必须等到IO操作完成返回,就是同步。调用aio_read,不用等IO操作完成直接返回,调用结果通过信号通知调用者。

对于多个线程来说,同步指线程间的步调一致,需要协调进程之间的执行时间,异步则不用。

  • 线程间同步的实现:

    获得线程对象锁,保证在同一时刻只有一个线程进入临界区

  • 线程间异步(?):

    一个线程可以通过异步消息传递机制向另一个线程发送消息,而不需要等待接收线程处理该消息

  • 同步调用:

    某个函数与之后的代码组成一个同步调用,该函数称为同步函数。执行完同步函数再执行之后的代码

  • 异步调用:

    请求返回时,不知道执行结果,需要通过其他机制获得结果,例如:主动轮询、被动通知(更高效),这两种方式也可以看做带通知的异步和不带通知的异步

1.3阻塞和非阻塞(等待消息通知时的状态)

  • 阻塞调用:调用结果返回之前,当前线程会被挂起
  • 非阻塞调用:在不能立刻得到结果之前,函数不会阻塞当前线程,而是会立刻返回,并设置相应的errno

线程的五种运行状态如下图,线程可以通过调用sleep在IO阻塞试图得到被占用的锁等待某个触发条件线程执行wait()方法 这几种方式进入阻塞态,引起线程阻塞的函数叫做阻塞函数。

1
2
3
4
5
6
7
8
9
stateDiagram-v2
[*] --> New : 创建线程
New --> Runnable : 调用start()
Runnable --> Running : 获得CPU
Running --> Runnable : 时间片用完
Running --> Blocked : 等待I/O或锁
Blocked --> Runnable : I/O或锁完成
Running --> Dead : run()方法结束或异常
Dead --> [*] : 线程销毁
  • 阻塞函数是一个同步函数,但是同步函数不一定是阻塞函数

  • **在Linux中,套接字有阻塞(默认)和非阻塞两种模式。**但是在默认模式下,调用bind(),listen()函数会立即返回(不发生阻塞);调用输入(recv, recvfrom),输出(send, sendto),接收连接(accept),外出连接(connect)会发生阻塞

  • 阻塞模式套接字的缺点:

    大量建立好的套接字线程之间的通信比较困难。

    如果使用生产者-消费者模型,为每个套接字分配读线程,数据处理线程,同步事件,会增大系统开销

    当需要处理大量套接字时,无从下手,扩展性差

1.4同步,异步,阻塞,非阻塞

  • 同步阻塞效率最低,因为线程在等待时直接阻塞,不能做任何其他事

  • 同步非阻塞比同步阻塞效率高一些,但仍然不够,因为在等待时,虽然可以执行其他任务,但是要隔一段时间查看等待结果是否完成,相当于在两个任务之间不停切换,所以效率低

  • 异步非阻塞效率最高,因为等待线程可以直接执行其他任务,通知等待完成由消息触发机制完成

  • 不存在异步阻塞,因为异步时,线程肯定要执行其他任务

1.5为什么采用 socket IO模型

使用单线程+同步阻塞通信在服务器端,例如recv()为阻塞式,当有多个客户端连接服务器,其中一个socket连接调用服务器线程的recv(),会产生阻塞,导致其他连接无法继续。在客户端,所有操作都在一个线程内顺序执行完成,通信操作阻塞其他操作,例如在图形界面调用socket通信,整个界面都会无响应。

如果使用多线程+同步阻塞通信,为每个socket连接创建一个线程,系统需要在所有可运行的线程进行上下文切换,浪费CPU时间,效率很低。

因此各个模型的目的都是为了实现多个线程同时访问不产生堵塞

1.6(同步)阻塞IO模型

优点:简单,实时性高,响应及时,无延时

缺点:需要阻塞等待,性能差

1.7(同步)非阻塞IO模型

优点:等待时可以做其他工作

缺点:任务完成的时延增加(因为采用轮询的方式检查内核,而该结果可能在两次轮询的间隔之间完成)

1.8(同步)IO多路复用模型

在该模型中,不再由应用程序自己监视连接,而由内核替应用程序监视文件描述符。它可以实现一个线程可以同时监视多个文件描述符,一旦有某个文件描述符准备就绪,就会通知应用程序,对该文件描述符进行操作。

例如select函数用于监视一组文件描述符的变化情况,如是否可以读写或者是否有异常发生。当用户进程调用了select函数,整个进程会被阻塞,并把要监视的文件描述符集合传递给内核,让内核去监视它们。当任何一个文件描述符的数据准备好(拷贝到内核),select就会返回,此时用户进程调用read或write操作,将数据从内核拷贝到用户空间。

在使用时,对于进程是被阻塞的,但是是被select函数阻塞,而不是IO操作阻塞,也就是说IO多路复用模型是阻塞在select或者epoll这样的系统调用,而不是IO系统调用(recvfrom)

优点:

  • 单个进程可以同时处理多个网络连接IO(但是需要多次系统调用)
  • 系统开销小,不用多线程/多进程(也是第一优点的延伸)

缺点:

  • 若连接数量少,并不一定比多线程+阻塞/非阻塞更快
  • select函数有一个限制,就是它只能监视1024个文件描述符(FD_SETSIZE),如果要监视更多的文件描述符,就需要使用poll或epoll等其他方式。
  • select函数每次都需要把文件描述符集合从用户空间拷贝到内核空间,并且每次都需要遍历所有文件描述符来检查哪些就绪了。这些都会增加系统开销和时间消耗。

1.9(同步)信号驱动IO模型

注册一个信号处理函数,进程继续运行不阻塞,当数据准备好时,进程收到SIGIO信号

1.10异步IO模型

用户进程调用aio_read调用后,可以处理其他逻辑,内核无论数据是否准备好都会直接返回。当数据准备好,内核会自动复制到用户空间再通知用户进程(两个阶段都非阻塞)。

Linux中对信号的三种处理方式:

  • 若进程在用户态处理其他逻辑,就强行打断,调用实现注册的信号处理函数(处理异步任务)。由于该调用类似中断,所以一般把该事件登记进队列然后返回原本的任务。
  • 若进程在内核态,就挂起通知,等到返回用户态再触发
  • 如果进程挂起则唤醒进程

二、(分时)循环服务器

适用于服务器和客户端一次传输的数据量比较小,每次交互时间短的场合。

网络情况较好,例如局域网用UPD,需要可靠性,例如互联网用TCP

  • UDP循环服务器

  • TCP循环服务器

三、多进程并发服务器

当客户端有请求时,服务器用一个子进程来处理客户请求,父进程继续等待其他客户端的请求

fork函数用于从一个已经存在的进程内创建一个新的进程,即子进程。该子进程为父进程的复制品(但不共享存储空间),除了进程号,资源使用情况,计时器等之外和父进程完全一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char* argv[]) // tcp fork server
{
创建套接字sockfd;
bind sockfd;
listen sockfd;
while(true)
{
int confd = accept();
if(fork() == 0) //子进程
{
close(sockfd);

func();

close(confd);
exit(0);
}
close(confd);
}
close(sockfd);
return 0;
}

四、多线程并发服务器

多进程服务器在创建进程时,消耗的系统资源较大,所以改为多线程(比多进程快一万倍)。但是一个进程内的线程共享资源,所以该机制存在同步问题。

五、IO多路复用服务器

IO多路复用服务器需要解决大量连接造成的性能下降问题。通过一种机制实现一个进程监视多个描述符,一旦某个描述符就绪,就能够通知程序进行相应的读写操作。

支持IO多路复用的系统调用有:select(POSIX),pselect,poll,epoll(Linux)。但这些调用本身是同步IO,需要在读写就绪后自己负责读写(阻塞)

IO多路复用的使用场景有以下几种:

  • 当需要处理大量的网络连接时,IO多路复用可以提高服务器的性能和响应速度,避免创建过多的线程或进程造成的开销和竞争。
  • 当需要同时处理不同类型的IO事件时,如键盘输入、鼠标移动、网络通信等,IO多路复用可以实现在一个线程或进程中统一管理这些事件,简化编程逻辑。
  • 当需要实现高并发的客户端程序时,如聊天软件、下载工具等,IO多路复用可以让客户端同时与多个服务器或其他客户端进行通信,提高用户体验。

5.1基于select的io多路复用

缺点:

  • select函数有一个限制,就是它只能监视1024个文件描述符(FD_SETSIZE),如果要监视更多的文件描述符,就需要使用poll或epoll等其他方式。

  • select函数每次都需要把文件描述符集合从用户空间拷贝到内核空间,并且每次都需要遍历所有文件描述符来检查哪些就绪了。这些都会增加系统开销和时间消耗。

  • select触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次调用select还是会将这些文件描述符通知进程。

    水平触发(LT)是指只要条件满足,对应的事件就会一直被触发。例如,如果文件描述符已经就绪可以非阻塞地执行IO操作了,此时会触发通知。

    边缘触发(ET)是指当套接字的缓冲状态发生变化时返回。对于读缓冲,有新到达的数据被添加到读缓冲时触发。对于写缓冲,当缓冲发生容量变更的时候触发(对端确认分组,内核删除已经确认的分组,空出空间,写缓冲容量发生变更)。

    水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次。水平触发会再次进行通知,而边缘触发不会再进行通知。所以,边缘触发需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN为止,EGAIN说明缓冲区已经空了。因为这一点,边缘触发需要设置文件句柄为非阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
sequenceDiagram
participant C as 客户端
participant S as 服务器
participant L as select
C->>S: 发送请求
S->>L: 调用select
L-->>S: 返回可读事件
S->>S: 从套接字读取数据
S->>S: 处理请求
S->>L: 调用select
L-->>S: 返回可写事件
S->>S: 向套接字写入数据
S->>C: 发送响应

5.2基于poll的io多路复用

  • poll没有最大文件描述符数量的限制(用链表保存描述符)

  • poll使用结构体pollfd来表示每个socket以及它等待发生的事件,而select使用一个数组存放所有同一种性质的socket

    1
    2
    3
    4
    5
    struct pollfd{
    int fd; //文件描述符
    short events; //等待的事件
    short revents; //实际发生的事件
    };
  • 但是poll也和select一样每次调用都要把文件描述符集合从用户空间拷贝到内核空间,也需要遍历

5.3基于epoll的io多路复用

epoll是select/poll的增强版本,能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

epoll相比于select和poll改进了之前提到的两个缺点。epoll使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次(只需要在第一次调用时将被监控的文件描述符集合从用户态空间拷贝到内核态空间,之后就不再需要拷贝了)。此外,epoll使用基于事件的就绪通知方式,通过回调函数来实现,不需要遍历整个被监控的描述符集,只需要判断有哪些描述符就绪了即可。

三个关键要素:

  • mmap:将用户空间的一块地址和内核空间的一块地址同时映射到同一块物理地址,减少用户态和内核态的数据交换
  • 红黑树:存储epoll监听的套接字,插入和删除性能好
  • 双端链表:一旦有事件发生,回调函数将该事件添加到链表中。调用epoll_wait时只需要检查链表中是否存在注册事件

因此,应用程序索引就绪文件描述符的时间复杂度由select和poll的O(n)降为O(1),并且支持前两个不支持的边缘触发模式。