前言

socket-io 是服务端高性能通信的基石,只有彻底弄清楚socket-io原理,才能真正理解一些高性能框架如rocketmq、netty、以及web容器的底层到底做了什么。

整个socket的知识体系很大,包括计算机网络协议、计算机组成原理(网卡、DMA)、操作系统的IO机制等,没办法一次性的全部展开。本文的切入点是解释清楚 socket 场景下,操作系统对 io 的处理过程。为了降低理解成本,将穿插一个现实场景下的例子阐述,办好小板凳,开始了~

注:本文只是介绍宏观的基本概念,具体技术细节将通过另外博客阐述,敬请关注后续内容。公众号: louluan_note(亦山札记)

本主要介绍socket-io的基本原理,如果想了解具体底层实现逻辑,请看我的另外一个博文 《socket-io的底层实现设计原理》


没有阻塞的代价

大龄程序员 亦山 因年龄过大,体力不行没机会继续修福报,被公司想社会输出人才,就这样失业了。但是总归要养家呀,于是乎就找了个档口,开了一家小餐馆,名为:“程序员再就业餐厅”。
由于资金有限,只能请得起 一名服务员Amy一个后厨 Tony,就这样,"程序员再就业餐厅"正式营业了。

老板亦山 规定了 服务员Amy的工作内容:

  1. 去前台接待顾客;
  2. 如果接待到顾客,则安排顾客就坐;
  3. 去每个就餐的餐桌上,看是否需要服务,服务内容有两项:
    3.1. 查看顾客是否已经下好单;如果已下单,则交给后厨tony 准备;
    3.2. 如果有菜已经准备好,并且餐桌还放得下,则给顾客上菜;
  4. 重复1、2、3
    (请注意: 这里描述的步骤转换成程序描述的语言,每个步骤都是非阻塞的。)

服务员Amy 的工作内容用流程图表示如下图:
服务员Amy的工作
再来看下餐厅里的情况:
在这里插入图片描述
将服务员Amy 的工作转换成代码,大致是这样的:

package org.luanlouis.socket.noblocking;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;

/**
 * 服务员抽象
 */
public abstract class AbstractWaiter {


    /**
     * 服务的餐桌列表
     */
    private List<Table> servedTableList = new ArrayList<>();

    /**
     * 前台接待顾客
     * 如果返回为空,表示 没有接待到
     * 此方法不阻塞
     * @return 顾客
     */
    public abstract Customer accept();


    /**
     * 取某个餐桌上的下单列表
     * 如果用户没有点好,或者没有加菜,则返回为空。
     * 此方法不阻塞
     *
     * @param table 餐桌
     * @return 下单列表
     */
    public abstract OrderList fetchOrderList(Table table);

    /**
     * 上菜
     * 此方法不阻塞,如果餐桌上有空余位置,则上菜,否则不上菜
     *
     * @param table 餐桌
     */
    public abstract Boolean serveDishes(Table table);


    /**
     * 服务入口
     * 主要工作项:
     >  1. 去前台接待顾客;
     >  2. 如果接待到顾客,则安排顾客就坐;
     >  3. 去每个就餐的餐桌上,看是否需要服务,服务内容有两项:
     >  3.1. 查看顾客是否已经下好单;如果已下单,则交给后厨`tony` 准备;
     >  3.2. 如果有菜已经准备好,并且餐桌还放得下,则给顾客上菜;
     >  4. 重复1、2、3
     */
    public void serve(){
        while(true){
            //前台接待顾客
            Customer customer = accept();
            if(null != customer){
                Table table = assignAvailableTable(customer);
                servedTableList.add(table);
                System.out.println("前台 接到顾客: " + customer.customerNo + ",分配到餐桌: "+table.getNumber());
            }else{
                System.out.println("前台尚未接待到顾客...");
            }
            //依次遍历服务的餐桌
            servedTableList.forEach(table-> {
                // 查看是否餐桌是否点好餐
                OrderList orderList = fetchOrderList(table);
                if(null == orderList || orderList.isEmpty()){
                    System.out.println("餐桌 " + table.getNumber() +" 顾客还没点好餐... ");
                }else{
                    System.out.println("餐桌 "+ table.getNumber() +" 已经点好餐:" + orderList +",交给后厨... ");
                }
                // 上菜
                serveDishes(table);
            });
        }
    }

    /**
     * 给用户分配餐桌
     * @param customer
     * @return
     */
    private Table assignAvailableTable(Customer customer) {
        String number = "A"+System.currentTimeMillis();
        return new Table(number,customer);
    }
}

调用方式:

package org.luanlouis.socket.noblocking;

/**
 * 服务入口
 */
public class Boostrap {
    public static void main(String[] args) {
        AbstractWaiter waiter = new Waiter();
        waiter.serve();
    }
}

就这样,服务员Amy 干了半天(6个小时),就哭着跑到老板亦山那里吵着说不干了。问其原因,她说:

第一:我一直在前台和各个餐桌上来回穿梭,一刻都没有停歇过啊;
第二:虽然我没歇着,感觉做的很多无用功啊!这半天里,总共就来了20个顾客,每个顾客平均从接待、点餐、到上菜花的时间是3分钟,占上午的 (20*3)/(6*60) = 1/6; 其余 5/6 的时间都浪费在前台和各个餐桌上来回穿梭
我又不是神奇女侠,干不了了,给我结钱走人!

服务员Amy工作模式的问题,转换成计算机表达的问题是:

一段代码在没有阻塞的情况下,持续让CPU在执行循环,而绝大部分的时间都是花在了验证执行条件是否满足上。这会导致CPU的利用率一直是100%,这本身是非常大的机器损耗;如果是在多线程工作模式下,还会影响到其他线程的执行时间(操作系统的线程时间片分配)。

听着 Amy 的哭诉,老板 亦山 觉得很有道理,这叫谁谁都扛不住,赶忙安慰安慰。这样下去肯定不是办法,老板亦山思考了片刻之后,说我给你换个工作方式吧。

阻塞的代价

老板 亦山 调整了服务员Amy 的工作方式:

  1. 在前台必须接待到顾客,如果没接到顾客,就一直等待,直到接到顾客;
  2. 依次去每一个顾客的餐桌上服务,服务内容不变,但是策略调整了:
    2.1 等待顾客下单,如果没有下单,就一直等待
    2.2 给顾客上菜,如果餐桌没有没有空余空间,就一直等待,直到顾客吃完其他的菜腾出空间来。

Amy 听后,非常高兴,只要条件不满足,她就一直等待,不用做没有意义的空跑了。工作模式如下图:
在这里插入图片描述
就这样工作了没多会儿,顾客就开始投诉了,说服务员的服务效率慢。老板 亦山一看,确实慢!来看看服务员Amy 慢在什么地方:

场景一前台阻塞服务员Amy 在前台等待顾客,但是顾客迟迟没有来;而之前接待好的入座的顾客,有的顾客点好菜下单了,但是迟迟没有交给后厨Tony 去做;还有的顾客是 后厨Tony的菜已经准备好了,但是迟迟没有服务员上菜;
在这里插入图片描述
场景二: 顾客点菜时阻塞服务员Amy 在等 A1餐桌的顾客点菜下单,但是顾客考虑了很久都没点好;而这时前台已经排了很长的队伍等待接待;另外A2,A3桌的顾客还在当着服务(点菜下单或上菜)
在这里插入图片描述

场景三: 上菜时阻塞服务员Amy 在给A2餐桌上菜,但是A2桌 之前已经上满了,没有多余空间了,所以Amy 在等待;而这时前台已经排了很长的队伍等待接待;另外A1,A3桌的顾客还在当着服务(点菜下单或上菜)
在这里插入图片描述
根据上述的几个阻塞场景可以看出,任何一个环节阻塞,将会导致其他顾客都得不到任何服务!
还有一个致命的问题是:任何一个阻塞场景是没有阻塞时间限制,有无限等待阻塞的可能:如前台等待,可能一直等不到任何顾客;等待顾客点餐,但是顾客可能就是迟迟不能下决定,甚至顾客还在等人;顾客吃的非常慢,桌子上一直没有空余出来空间能够上新的菜…

老板亦山 这么一分析,这可不行,这样的话,我这一天接待不了多少顾客,要血亏的… 转念一想,既然前台、和每个接待的餐桌都可能阻塞,我多招些服务员,每个人负责一个餐桌不就可以缓解了吗?

多线程模式-缓解IO处理能力方式之一

基于上述的惨痛的代价,老板亦山 决定多雇点员工,保证前台和每个餐桌上都有一个服务员可接待,大概的工作模式如下图所示:
在这里插入图片描述
这里对服务员做了简单的区分:

  • 前台接待服务员:工作内容是在前台接待顾客(没有顾客上门时阻塞),安排到一个座位上,然后指定服务员负责接待
  • 餐桌服务员:工作内容是 接待顾客,等顾客点餐(未拿到点餐列表时阻塞),拿到订餐列表交给后厨;给顾客上菜(餐桌已满没地方放时阻塞)

在代码层面职能描述如下:

/**
 * 前台服务员
 */
public class Receptionist extends AbstractWaiter {

    ExecutorService tableWaiterPool = Executors.newFixedThreadPool(20);

    /**
     * 前台接待顾客
     * 如果返回为空,表示 没有接待到
     * 此方法不阻塞
     * @return 顾客
     */
    public Customer accept(){
        //模拟没有接待到的场景
        if( new Random(10).nextInt(3) ==2){
            return null;
        }
        return new Customer("Customer_"+System.currentTimeMillis());
    }
    
    @Override
    public Table assignAvailableTable(Customer customer) {
        return null;
    }

    /**
     * 服务内容:
     * 1. 前台接待
     * 2. 给接待的顾客安排座位并且指定服务员
     */
    @Override
    public void serve() {
        while(true){
            //前台接待顾客,可能阻塞
            Customer customer = accept();
            if(null != customer){
                final Table table = assignAvailableTable(customer);
                servedTableList.add(table);
                System.out.println("前台 接到顾客: " + customer.customerNo + ",分配到餐桌: "+table.getNumber());

                //分配服务员
                tableWaiterPool.submit(new Runnable() {
                    public void run() {
                        new TableWaiter(table).serve();
                    }
                });
            }else{
                System.out.println("前台尚未接待到顾客...");
            }
        }
    }
}

而餐桌服务员的工作内容如下:

/**
 * 餐桌服务员
 */
public class TableWaiter extends Waiter {

    public Table table;
    public TableWaiter(Table table) {
        this.table = table;
    }

    /**
     * 主要做两件事:
     * 1. 等待顾客下单(如果顾客迟迟没有下单,在一直等待)
     * 2. 上菜,如果餐桌已满,则可能阻塞等待
     */
    @Override
    public void serve() {
        while(true){
            // 查看是否餐桌是否点好餐,可能阻塞
            OrderList orderList = fetchOrderList(table);
            if(null == orderList || orderList.isEmpty()){
                System.out.println("餐桌 " + table.getNumber() +" 顾客还没点好餐... ");
            }else{
                System.out.println("餐桌 "+ table.getNumber() +" 已经点好餐:" + orderList +",交给后厨... ");
            }
            // 上菜,可能阻塞
            serveDishes(table);
        }

    }
}

每个餐桌一个服务员的模式,乍一看是缓解了一个只有一个服务员处理处理不过来接待和服务的问题,但是老板亦山 月底做结算的时候发现:

  • 不得了,店面的收入远远不及员工工资的!
  • 另外虽然每个餐桌配备了服务员,实际上服务员真正工作的时间(下单和上菜)非常短,其他时间都在做无谓的等待

这个代价太大了!这种模式转换成计算机语言表述是:

多线程模式下的socket-io 能够有效地缓解了 当系统中io过多中,io因阻塞问题来不及处理的吞吐问题;但是引入了多线程模式, 会导致线程数量会随着请求数直线膨胀;操作系统内线程数过多会导致CPU时间片分配会被严重打散,每个线程的处理请求所需要的时间会被大幅延长,另外由于线程过多,导致操作系统要分配大量的内存来维护线程上下文,也增加了空间成本。
所以可以得出一个结论:靠分配独立线程的模式无法解决多IO操作的问题。

最终,迫于无奈,老板亦山 还是辞退和其他的服务员,就只留下了 服务员Amy厨师Tony

基于IO通知的多路复用 - Polling 原理

老板亦山 再回头思考了三种阻塞场景:

  • 前台接待顾客(如果没有等到,就一直阻塞)
  • 餐桌服务,获取顾客下单列表(如果没下单,就一直阻塞)
  • 餐桌服务,给顾客上菜(如果餐桌没有空余位置,就一直阻塞)

如果让服务员服务,每个环节都有可能阻塞。实际上阻塞的原因是因为顾客的需求还没准备好,那能不能换个思路呢?如果是顾客需要服务的时候,通知 服务员Amy 一下,然后Amy在到前台或者各个餐桌上,(如前台顾客已到达、餐桌上顾客菜单点好、餐桌上已经空出空余空间) 那基本上就不存在等待阻塞的情况了!

于是乎,老板亦山 对店面做了升级,并对服务员Amy的服务方式做了调整:

  • 在前台和每个餐桌上安排了一个闹铃,如果需要服务,顾客就按下闹铃,闹铃通知接收器会发出声响;
  • 服务员Amy 身上安装一个闹铃通知接收器,如果接收到了通知,则表示需要服务了,否则就一直等待。但是这个接收器功能有个限制:不知道是具体前台或者哪个餐桌具体发出的,需要到前台和餐桌一个个遍历一遍去查看到底是哪些满足条件(询问的过程是非阻塞的),找到后再提供服务。

在这里插入图片描述
在这种工作模式下,服务员Amy 的工作模式就变成了:

  1. 等待闹铃(如果没接收到,一直阻塞);
  2. 如果闹铃响了,则依次遍历前台和所有的餐桌,看哪些需要服务(询问的过程是非阻塞的),然后提供服务
/**
 * 基于闹铃通知工作模式的服务员
 */
public abstract class MultiWaiter extends AbstractWaiter {


    /**
     * 如果没有触发,则一直等待
     * @return true
     */
    public abstract boolean alarmTriggered();

    /**
     * 是否 下好单
     * @return
     */
    public abstract boolean isOrderListReady(Table table);

    /**
     * 餐桌上是否有空余空间
     * @return
     */
    public abstract boolean isTableAvailable(Table table);

    /**
     * 服务入口
     */
    public void serve(){
        //等待闹铃触发,如果没有,则一直等待
        while(alarmTriggered()){
            servedTableList.forEach(item->{
                //判断是否已经下好单,非阻塞
                if(isOrderListReady(item)){
                    //TODO:  菜单列表交给后厨做菜
                }
                //判断是否有空余空间,非阻塞
                if(isTableAvailable(item)){
                    //TODO:  上菜
                }
            });
        }
    }
}

上述服务员Amy 可以监听前台和所有餐桌的闹铃通知的模式,在计算机语言中,被称为多路复用-multiplexor,接收到闹铃通知后,然后逐次遍历前台和各个餐桌的过程,在计算机语言中,称为Polling -轮询模式

这种Polling-IO 模式 是 Windows 和早期Linux 2.6 之前的主要支持模式。Polling 的主要问题是 如果 socket 连接过多,而基于这种通知模式,需要依次轮询每个socket 查看状态,这个势必造成极大的性能损耗。

这种模式下,老板亦山 发现服务员Amy 的服务顾客数显著增加,并且也没有这么疲劳了。开了一段时间之后,发现收入非常可观,想扩大店面,之前是10个餐桌,现在扩大到100个。

服务员Amy 有不开心了她说:老板,现在100个餐桌+一个前台,只要有一个按了闹铃,我要把所有的餐桌都要遍历一遍,这个效率太低了啊,我跑了太多的冤枉路,能不能升级下你的闹铃,闹铃响的时候,显示下是哪个餐桌或前台按的?

老板亦山 想了想,说:“好,虽然升级闹铃需要额外成本,为了你的健康和效率,必须升级”。

提升Polling的效率-epoll原理

升级后的餐馆,变成了这个样子:
在这里插入图片描述
前台和餐桌的真正触发了通知,会被记录在一个消息队列中,在服务员Amy 从闹铃等待恢复的过程中,遍历的列表是真正触发通知的。这样,Amy 的服务能力又能提升很多倍!

这种模式在计算机语言中,被称为epoll 模式,在Linux 2.6 及以后的内核得到了支持。

本主要介绍socket-io的基本原理,如果想了解具体底层实现逻辑,请看我的另外一个博文 《socket-io的底层实现设计原理》


注:本文只是介绍宏观的基本概念,具体技术细节将通过另外博客阐述,敬请关注后续内容。公众号: louluan_note(亦山札记)

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐