不落辰

知不可乎骤得,托遗响于悲风

0%

服务器设计范式

服务器模型介绍 学习自硬核课堂

服务器设计范式

基本组件

  • 无论服务端设计什么样的模型,但其基本组件是不变的,不同的在于如何进行巧妙、高效的组合。

迭代式

  • client在server端进行排队被server接收,因此并不适合繁忙Server。

每个用户一个进程

  • main负责accept,child proces负责业务

    • main 每当 accept一个conn时,就fork出一个子进程。子进程负责处理这个conn的业务逻辑。
  • 缺点:用户量大时,非常消耗资源。

  • 核心逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    for ( ; ; ) 
    {
    clilen = addrlen; //把客户地址结构体大小复制一份
    if ( (connfd = accept(listenfd, cliaddr, &clilen)) < 0) { //响应客户端请求,返回连接到客户端的套接字
    if (errno == EINTR) //被信号打断则继续
    continue; /* back to for() */
    else
    err_sys("accept error"); //其他错误无法忍受
    }
    //创建一个子进程
    if ( (childpid = Fork()) == 0) { /* child process 在子进程中 */
    Close(listenfd); /* close listening socket 关闭监听套接字 */
    web_child(connfd); /* process request 在这个函数中响应客户的请求*/
    exit(0); //退出子进程
    }
    Close(connfd); /* parent closes connected socket 父进程中关闭连接到客户端的套接字,继续监听等待客户端的连接 */
    }

每个用户一个进程 + 提前创建好进程池 (prefork模型)

  • main只负责fork,子进程负责accept和业务

    • main提前fork出n个子进程,在每一个child process里面accept一个新来的IO连接conn, 并在该child process中处理该conn的业务逻辑。
      • 也即,accept接收连接 和 wechild业务处理 都是在child process中进行的。
  • 所以当新来一个连接时,在多个子进程都可以accept,但最终只能有一个子进程接收该connection,accept处会存在竞争,所以需要lock。

  • 引入(进程池)池技术,有效的避免了在用户到来时进程执行fork的开销,然而需要在启动阶段预估判断多少个子进程,而且由于是多进程,耗费资源比较大,因此并发有限。

  • 核心逻辑

    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
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    //  1. main提前fork出n个子进程
    // 每个子进程负责一个 connection 的 accept 和 业务处理(webchild)
    my_lock_init("/tmp/lock.XXXXXX");
    for (i = 0; i < nchildren; i++) //用循环创建子进程池
    pids[i] = child_make(i, listenfd, addrlen); /* parent returns */

    // fork
    // parent 返回
    // child 运行child_main() [accept() and webchild()]
    pid_t
    child_make(int i, int listenfd, int addrlen)
    {
    pid_t pid;
    void child_main(int, int, int);

    if ( (pid = Fork()) > 0) //创建子进程(并且在父进程中)
    return(pid); /* parent 在父进程中,向本函数调用者返回子进程的pid */
    // 传递listenfd给child process
    child_main(i, listenfd, addrlen); /* never returns 在子进程中,调用这个函数进行处理 */
    }
    /* end child_make */
    // 2. child thread runs accept接受连接 and webchild业务逻辑
    /* include child_main */
    void
    child_main(int i, int listenfd, int addrlen)
    {
    int connfd;
    void web_child(int);
    socklen_t clilen;
    struct sockaddr *cliaddr;
    void my_lock_wait();
    void my_lock_release();

    cliaddr = Malloc(addrlen); //分配存放客户地址的地址结构体空间

    printf("child %ld starting\n", (long) getpid());
    for ( ; ; ) {
    clilen = addrlen;
    // 上锁 防止惊群
    my_lock_wait();
    connfd = Accept(listenfd, cliaddr, &clilen); //产生连接到客户的套接字
    my_lock_release();

    web_child(connfd); /* process the request 响应客户的请求*/
    Close(connfd); //关闭连接到客户的套接字
    }
    }

每个用户一个线程

  • 相比于多进程模型,如果服务器主机提供支持线程,我们可以改用线程以取代进程。
  • main 每当accept一个conn时,就pthread_create,将新接收的连接(fd)交由subthread进行业务处理。
    • main处理accept连接,subthread处理webchild业务
  • 线程相比于进程的优势节省资源,一般场景够用了。但是如果一个web服务器并发量过万,可能同时会创建1w个线程,此时看看你的服务器支不支持的住哟。
  • 核心逻辑
    1
    2
    3
    4
    5
    6
    7
    8
    for ( ; ; ) 
    {
    clilen = addrlen;
    iptr = Malloc(sizeof(int)); // 分配一个int大小的空间(存放文件描述符),每次循环分配一个新的空间
    *iptr = Accept(listenfd, cliaddr, &clilen); // 响应客户请求,返回连接到客户端的套接字
    // 创建一个线程来处理客户的请求,线程属性为默认,把连接到客户端的套接字当参数传递给线程
    Pthread_create(&tid, NULL, &doit, (void *) iptr);
    }

每个用户一个线程 + 提前创建好线程池

  • 与prefork模型类似,只不过是把进程换成了线程。
    • 子线程负责accept和webchild。accept处存在竞争,需要上锁。
  • 核心逻辑
    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
    37
    38
    39
    40
    41
    //  main创建线程池
    // 每个subthread 负责 accept连接 和 webchild业务处理
    for (i = 0; i < nthreads; i++) //创建规定数目的线程并做相应的操作
    thread_make(i); /* only main thread returns */

    void
    thread_make(int i)
    {
    void *thread_main(void *);
    int *val;
    val = Malloc(sizeof(int));
    *val = i;
    // 创建线程,线程属性为null,线程id填写到线程结构体中,索引i按参数传递给线程
    Pthread_create(&tptr[i].thread_tid, NULL, &thread_main, (void *) val);
    return; /* main thread returns */
    }

    void *
    thread_main(void *arg)
    {
    int connfd;
    void web_child(int);
    socklen_t clilen;
    struct sockaddr *cliaddr;

    cliaddr = Malloc(addrlen); //分配客户端地址结构体空间

    printf("thread %d starting\n", *((int*)arg));
    free(arg);

    for ( ; ; ) {
    clilen = addrlen;
    Pthread_mutex_lock(&mlock); // 防止惊群
    connfd = Accept(listenfd, cliaddr, &clilen); //在互斥锁的保护下accept
    Pthread_mutex_unlock(&mlock);
    tptr[(int) arg].thread_count++; //这个线程处理的客户数目递增1

    web_child(connfd); /* process request 在此函数中响应客户的请求 */
    Close(connfd); //关闭连接到客户的套接字
    }
    }

主线程统一accept + 提前创建好线程池

  • 主线程统一accept所有conn,每个subthread负责处理一个fd的业务逻辑

    • main负责accept所有conn,每accept一个conn,就从先前创建好的线程池中唤醒一个线程,将fd传入给该subthread
    • 不用对accept上锁,又避免了多线程accept的惊群效应
  • 核心逻辑

    • main线程accept连接之后,通过条件变量唤醒subthread来取走这个建立的conn的fd。subthread负责处理fd的业务。
      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
      /* 4create all the threads */
      // 提前创建好线程池
      // 通过条件变量使得每个thread阻塞
      for (i = 0; i < nthreads; i++) //在thread_make函数中循环创建线程并做相应处理
      thread_make(i); /* only main thread returns */

      Signal(SIGINT, sig_int); //捕捉ctrl+c信号

      for ( ; ; ) {
      clilen = addrlen;

      // 主线程统一accept
      connfd = Accept(listenfd, cliaddr, &clilen); //响应客户请求,并返回连接到客户的套接字

      Pthread_mutex_lock(&clifd_mutex); //给全局变量(线程信息结构体的数组)加锁
      clifd[iput] = connfd; //把描述符存入
      if (++iput == MAXNCLI) //如果下标已经到达最大值,则回绕
      iput = 0;

      //如下条件成立说明主线程已经往数组中放入了过多的描述符,而仍没有线程去取出(一下子有太多的客户连接,线程忙不过来)
      if (iput == iget) //如果线程取出描述符的下标和主线程放入描述符的下标相等,则出错
      err_quit("iput = iget = %d", iput);

      // 唤醒负责处理fd的业务逻辑的subthread

      Pthread_cond_signal(&clifd_cond); // 给条件变量发消息
      Pthread_mutex_unlock(&clifd_mutex); // 解锁互斥锁
      }

事件驱动 event-based(只讨论reactor)

  • 核心:利用IO复用接口

  • 反应器设计模式指的是由一个或多个客户机并发地传递给应用程序的服务请求。一个应用程序中的每个服务可以由几个方法组成,并由一个单独的事件处理程序表示,该处理程序负责调度特定于服务的请求。事件处理程序的调度由管理已注册事件处理程序的启动调度程序执行。服务请求的解复用由同步事件解复用器执行。也称为调度程序、通知程序。其核心是os的IO复用(epoll_开头的相关)接口。

  • 基本思路:

    • 主线程往epoll内核事件表注册socket上的读事件。
    • 主线程调用epoll_wait等待socket上数据可读。
    • 当socket可读时,epoll_wait通知主线程,主线程则将socket可读事件放入请求队列。
    • 睡眠在请求队列上的某个工作线程被唤醒,他从socket读取数据,并处理用户请求,然后再往epoll内核时间表中注册socket写就绪事件。
    • 主线程epoll_wait等待socket可写。
    • 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
    • 睡眠在请求队列中的某个线程被唤醒,他往socket上写入服务器处理客户请求的结果。
    • 对比一下muduo,没有请求队列,而是通过eventfd?
  • 优点
    • 响应快,不必为单个同步操作所阻塞,也不用考虑fd跨线程问题.
    • 可扩展性,可以很方便的通过增加reactor实例(如multireactor)个数来利用CPU资源;
    • 可复用性,reactor本身与具体事件处理逻辑无关,便于复用。
  • 缺点
    • 共享同一个reactor时,若出现较长时间的读写,会影响该reactor的响应时间,此时可以考虑thread-per-connection。(即每一个连接给一个线程)

reactor 单线程模型

  • The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handler

  • 参考

  • 参考

  • 源码

  • reactor 单线程模型 从 accept接收连接 到 connfd业务逻辑 都是在一个线程上处理的

    • 重要组件:
      • Reactor:负责epoll_wait
      • dispatch:负责根据wait到的Event派发相应handler
      • handler:
        • acceptor:负责连接的建立
        • read/write handler:业务处理

  • reactor单线程的另一种更通用的理解方式:

    • 重要组件:
      • Event事件:绑定了fd,events 以及handler
      • Reactor反应堆:存储了event的集合
      • Demultiplex事件分发器:基于IO复用 如epoll
      • EventHandler事件处理器:handler
    • 和上面的图大同小异


  • 消息处理流程:

    • Reactor对象通过select监控连接事件,收到事件后通过dispatch进行转发。
    • 如果是新来的connection,则由acceptor接受连接,并创建handler处理后续事件。
    • 如果是connfd的读写事件,则Reactor会分发调用Handler来响应。
      • 按顺序逐个调用handler
      • 一个服务端可以同时处理多个客户端,是指的一个服务端可以监听多个客户端的连接、读写事件,真正做业务处理还是“一夫当关,万夫莫开”的效果。(即只有处理完一个event,才能继续处理下一个event)
    • handler会完成read->业务处理->send的完整业务流程。
  • 得益于epoll的高性能,一般场景够用了。我们的Redis就是使用的是单线程版的reactor。

  • 单Reactor单线程模型只是在代码上进行了组件的区分,但是整体操作还是单线程,但是不能发挥出多核CPU的优势。handler业务处理部分没有异步。

单reactor + 工作线程池

  • 单线程的reactor,业务处理也在IO线程中,此时如果有耗时操作,会影响并发。因此我们使用工作线程池来异步耗时的操作。

multi-reactor 版本1

  • 在上文分析中,我们发现单线程的reactor太忙了,

    • 既当爹 :监听listenfd的连接事件。
    • 又当妈的:监听connfd的读写事件。
    • 那我们干脆直接把他在拆开不就行了吗?这样的话,是不是能响应更多的并发了?
  • 简化版

  • 不一定需要工作线程池。muduo就没有。muduo是个每个subthread上跑一个loop。也即每个subthread就是一个reactor single process(这就是subReactor)。每个loop上用一个thread负责监听和处理若干connfd的连接。而main线程 也即主reactor上跑的loop 是用来监听listenfd的。并把新建立的connfd派送给subReactor去监听。

  • muduo也没有task queue这个缓冲队列。而是通过eventfd唤醒相应subthread,去处理该subthread上的事件。

  • muduo的subthread = 0时,也即没有subreactor时,muduo实际上只跑了一个mainReactor。这个Reactor就是reactor single process。这个单线程 既监听并处理listenfd,又监听并处理connfd

Muduo multiple Reactor

  • muduo库的Multiple Reactors模型

multi-reactor版本2

  • 其实这个版本是没有啥意思的,因为他出现前提是main reactor(即负责处理监听套接字的线程响应不过来),天了噜,这个得多大的并发?我估计也就tomcat那种可能会用到,因此这个不具体给出。