ARTS 打卡第二周

172 阅读12分钟

1 Algorithm 一道算法题

本周算法题为移除元素,下面是我的三种解题思路以及对应实现

leetcode.cn/problems/re…

2 Review 读一篇英文文章

本周阅读的文章是“大哥李”的《Scalable IO in Java》,获益良多

image.png 大纲 可扩展的网络服务 基于事件驱动的处理 Reactor 模式:

  • 基础版本
  • 多线程版本
  • 其他变种

java.nio 非阻塞 IO API 介绍

image.png Web 和分布式对象等大多数都有相同的基础结构:

  • 读请求
  • 解码请求
  • 处理服务
  • 编码响应
  • 发送响应

但是每一步的性质和开销不同:XML 解析,文件传输,Web 页面生成,计算服务等

image.png 经典的服务设计:per client per thread(单连接单线程模式),即每个连接单独用一个线程进行处理,下面是对应 Java 代码例子

class Server implements Runnable {
    public void run() {
        try {
            ServerSocket ss = new ServerSocket(PORT);
            while (!Thread.interrupted())
                new Thread(new Handler(ss.accept())).start();
            // or, single-threaded, or a thread pool
        } catch (IOException ex) { /* ... */ }
    }
    static class Handler implements Runnable {
        final Socket socket;
        Handler(Socket s) { socket = s; }
        public void run() {
            try {
                byte[] input = new byte[MAX_INPUT];
                socket.getInputStream().read(input);
                byte[] output = process(input);
                socket.getOutputStream().write(output);
            } catch (IOException ex) { /* ... */ }
        }
        private byte[] process(byte[] cmd) { /* ... */ }
    }
}

可以看到单连接单线程处理模型无法应对大量请求的情况 image.png 可扩展性的目的:

  • 负载增加时(更多客户端)实现优雅地降级
  • 随着资源(CPU、内存、硬盘、带宽)的增加,持续改进
  • 同时满足可用性和性能目标
    • 短延迟
    • 满足峰值需求
    • 可调节的服务质量
  • 分而治之通常是实现任何可扩展性目标的最佳方法

image.png 将处理流程划分为多个小任务,每个小任务非阻塞的处理一个操作 当任务准备好时,执行每个任务,IO 事件通常作为触发器 java.nio 支持的基本机制:

  • 非阻塞的读写
  • 分派与 IO 事件相关的任务

一系列基于事件驱动的设计

image.png 基于事件驱动的设计的特点:

  • 通常比其他方案更为高效
    • 更少的资源——通常不需要每个client用一个单独的线程来处理
    • 更少的开销——更少的上下文切换,通常更少使用锁
    • 分派任务可能稍慢——必须手动将操作绑定到事件上
  • 通常编程难度更高
    • 必须将其分解为简单的非阻塞操作:跟 GUI 的事件驱动操作类似,不能消除所有阻塞,比如 GC,页失效等
    • 必须跟踪服务的逻辑状态

更少的上下文切换,通常更少使用锁该怎么理解?

在传统的线程模型中,每个线程都有自己的运行上下文,所以线程之间的切换会涉及到保存和恢复大量的运行上下文信息,这会带来较大的开销。而在事件驱动的设计中,通常只有一个事件循环线程来处理所有的任务,因此上下文切换的开销会大大减少。

此外,由于任务之间是非阻塞的,因此在任务处理之间可以不使用锁定来同步,这也会减少锁的使用次数。举例来说,在一个Web服务器中,通过事件驱动的设计来处理HTTP请求,避免了在传统的多线程模型中在请求处理过程中进行线程切换和锁竞争。这不仅提高了性能,还使代码更容易理解和维护。

必须跟踪服务的逻辑状态该怎么理解?

在事件驱动的设计中,通常将服务分解为许多小任务,这些小任务通常都是非阻塞的,因此事件驱动的服务处理逻辑会在多个任务之间切换。在这种情况下,我们必须始终掌握服务的当前状态,以确保下一个任务能够在正确的条件下执行。

例如,在一个网络服务中,我们可能会将某个请求转换为事件,并创建相应的任务链来异步处理这个请求。在这个任务链中,每个任务都需要知道先前的任务是否成功完成,以决定下一步该做什么,而这就需要跟踪服务的状态。另外,一些处理任务可能会修改服务的状态,因此我们需要确保服务的状态被正确地处理并维护,在整个服务的生命周期中都是正确的。

image.png 以 AWT 中事件为例,每个操作都会生成一个事件对象放入 AWT 事件队列,AWT 线程从队列中取事件进行处理

IO 事件驱动也是用了相似的思想但采用不同的设计(reactor 模式)

image.png Reactor 通过分派适当的处理程序来响应 IO 事件——这与 AWT 线程中通过分派事件处理程序来响应用户界面事件非常相似。

处理器执行非阻塞操作——这与 AWT 的 ActionListener 也需要快速响应事件非常相似。

通过将处理程序绑定到事件来管理——跟 AWT 的 addActionListener 相似

image.png 上面是基础的单线程版本的 Reactor 设计

一个 Reactor 线程既处理连接请求,又处理数据的读写,同时还负责数据的逻辑处理过程

用 java 代码实现如下:

class Reactor implements Runnable {
    final Selector selector;
    final ServerSocketChannel serverSocket;

    Reactor(int port) throws IOException {
        selector = Selector.open();
        serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(
                new InetSocketAddress(port));
        serverSocket.configureBlocking(false);
        SelectionKey sk =
                serverSocket.register(selector,
                        SelectionKey.OP_ACCEPT);
        sk.attach(new Acceptor());
    }

    /*
    Alternatively, use explicit SPI provider:
    SelectorProvider p = SelectorProvider.provider();
    selector = p.openSelector();
    serverSocket = p.openServerSocketChannel();
    */
    // class Reactor continued
    public void run() { // normally in a new Thread
        try {
            while (!Thread.interrupted()) {
                selector.select();
                Set selected = selector.selectedKeys();
                Iterator it = selected.iterator();
                while (it.hasNext())
                    dispatch((SelectionKey) (it.next());
                selected.clear();
            }
        } catch (IOException ex) { /* ... */ }
    }

    void dispatch(SelectionKey k) {
        Runnable r = (Runnable) (k.attachment());
        if (r != null)
            r.run();
    }
}

// class Reactor continued
class Acceptor implements Runnable { // inner
    public void run() {
        try {
            SocketChannel c = serverSocket.accept();
            if (c != null)
                new Handler(selector, c);
        } catch (IOException ex) { /* ... */ }
    }
}

final class Handler implements Runnable {
    final SocketChannel socket;
    final SelectionKey sk;
    ByteBuffer input = ByteBuffer.allocate(MAXIN);
    ByteBuffer output = ByteBuffer.allocate(MAXOUT);
    static final int READING = 0, SENDING = 1;
    int state = READING;
    Handler(Selector sel, SocketChannel c)
            throws IOException {
        socket = c; c.configureBlocking(false);
// Optionally try first read now
        sk = socket.register(sel, 0);
        sk.attach(this);
        sk.interestOps(SelectionKey.OP_READ);
        sel.wakeup();
    }
    boolean inputIsComplete() { /* ... */ }
    boolean outputIsComplete() { /* ... */ }
    void process() { /* ... */ }
    // class Handler continued
    public void run() {
        try {
            if (state == READING) read();
            else if (state == SENDING) send();
        } catch (IOException ex) { /* ... */ }
    }
    void read() throws IOException {
        socket.read(input);
        if (inputIsComplete()) {
            process();
            state = SENDING;
// Normally also do first write now
            sk.interestOps(SelectionKey.OP_WRITE);
        }
    }
    void send() throws IOException {
        socket.write(output);
        if (outputIsComplete()) sk.cancel();
    }
}

image.png

工作线程池模型如上所示:一个 Reactor 用于处理连接请求,并处理 IO 数据的读写,然后将数据交给线程池进行处理,线程池处理的数据主要包括编解码以及数据的逻辑处理等

class Handler implements Runnable {
    // uses util.concurrent thread pool
    static PooledExecutor pool = new PooledExecutor(...);
    static final int PROCESSING = 3;
    // ...
    synchronized void read() { // ...
        socket.read(input);
        if (inputIsComplete()) {
            state = PROCESSING;
            // 数据的处理交给线程池
            pool.execute(new Processer());
        }
    }
    synchronized void processAndHandOff() {
        process();
        state = SENDING; // or rebind attachment
        sk.interest(SelectionKey.OP_WRITE);
    }
    class Processer implements Runnable {
        public void run() { processAndHandOff(); }
    }
}

image.png " Handoffs(交接) 每个任务启用、触发或调用下一个任务 通常情况下更加快速,但是可能更脆弱

此处指协调任务时的一种策略,即将一个任务的结果作为另一个任务的输入数据,通过逐个调用这些任务来完成整个任务流程。这种策略虽然效率高,但是关联度较强,一旦其中一个任务出现问题,整个任务流程就会受到影响。

" Callbacks to per-handler dispatcher(回调到每个处理程序的分配器) 设置相关状态、附加等内容 是 GoF 中中介者模式的一种变体

此处指通过回调函数(Callback)来实现任务之间的协调,将任务完成的状态、附加信息等传递给各个处理程序来实现任务协调的目的。这种策略比较灵活,可以实现不同任务之间的解耦,适用于可组装、可复用的场景。

" Queues(队列) 例如,在各个阶段之间传递缓冲区

此处指通过队列来实现任务协调,各个任务之间通过向队列中放入数据,或从队列中取出数据来传递信息,完成任务协调的目的。这种策略比较通用,适用于消费者-生产者场景或管道模式等。

" Futures 当每个任务产生结果时,使用的一种技术 是建立在 join 或 wait/notify 之上的协调技术

此处指通过 Futures 技术来实现任务的协调,即让前面的任务产生 Future 对象,后面的任务可以在对 Future 进行等待的同时继续执行其他任务。等待 Future 完成之后,后面的任务再通过 Future 获取前面任务的结果。这种策略比较适用于可并行的场景,能够优化并发程序的性能。

综上所述,上面的文字主要是关于多任务协调的内容,介绍了常用的四种协调策略,即 Handoffs、Callbacks、Queues 和 Futures。这些策略各有优缺点,应根据场景和需求灵活选择合适的策略来实现任务的协调。

image.png

  • 策略性地添加线程以实现可扩展性:在多处理器环境中,通过增加线程的数量可以提高系统的可扩展性
  • 工作线程:这一部分涉及了多线程程序中的反应器模式,即通过使用响应式编程的思想,快速触发处理器来处理事件。同时也指出处理器的处理速度可能会变慢,并建议将一部分非 IO 处理卸载到其他线程上去处理,从而确保反应器的速度。
  • 多 Reactor 线程:在多线程程序中,如果使用反应器模式处理 IO 操作,反应器线程的处理任务可能会变得非常繁重,从而导致性能下降。为了优化程序性能,建议将反应器的负载分配到其他反应器上去执行。负载平衡以匹配CPU和IO速率。

image.png

  • 将非 IO 处理卸载到工作线程来加快反应器线程
  • 比重新设计计算密集型处理任务为事件驱动形式更简单:将计算密集型任务转换为基于事件驱动模式的形式需要更多的开发工作和更高的技术难度,相比之下,使用工作线程来处理计算密集型任务更加简单。使用纯非阻塞计算。确保工作线程的处理量足够大,以抵消线程开销
  • 重叠的处理 IO 是困难的,最好能够第一时间将所有数据读入缓冲
  • 使用线程池以便进行调优和控制

image.png 介绍了一种多 Reactor 线程的设计模式,通过反应器池(Reactor Pools)来实现。这种设计模式的目的是为了匹配 CPU 和 IO 的速率,提高程序的并发能力和响应能力。在这个模式中,每个工作线程都拥有自己的 Selector 和分发循环,可以负责处理来自IO操作通道的事件(如连接、读写等)。主 Acceptor(接收器)作为入口,负责接收所有连接请求,然后将连接分发给其他 Reactor 线程。同时,这个模式还支持静态或动态的构建,可以根据具体需求来选择。 image.png 其中一般 mainReactor 使用单线程,主要处理连接请求,subReactor 使用多线程,主要处理数据的读取和发送,将数据的其余处理交给工作线程池来进行

3 Techniques/Tips 分享一个小技巧

尽早暴露问题,当遇到问题时,尽早将问题抛出寻求帮助解决,避免对项目进程造成影响,很多时候,你自己觉得做了很多工作,但出问题时背锅的都是你,就是这个原因,我们程序员容易陷入细节,很多时候细节对我们造成了阻塞,我们加班解决,但是有时候却不能赶上进度或者解决后遗留有额外问题,导致项目出错,其实我们遇到问题时将问题及时抛出寻求组内其他成员的帮助,甚至是老板的帮助,很多问题由于我们所在的位置,对项目整体的上下文了解不足,觉得很难解决,但是站在老板的角度,更大的上下文下,这个问题可能并不是一个大问题,甚至有时根本不用解决,老板不怕你暴露问题,怕的是你一个人自己搞,最后影响了项目的交付,因此不要怕抛出问题,要大胆的抛出问题

4 Share 分享一个观点

本周在极客时间学习了 10x 程序员工作法则,获益良多,下面列举出两个收益最大的心得

  1. 以终为始,从结果倒推过程,包括制定 DoD,从用户故事角度分析需求,从上线倒推工作计划等,并且还学会了如何扩充自己的上下文,你和你老板工作的不同在于你们的上下文不同,要学会探索编程以外的上下文,比如了解系统整体运行逻辑,领域间的练习等,这样你才能抓住机会向上爬,还要学会向上管理,降低老板心理预期,帮助老板解决问题,及时跟老板沟通(比如想参与某某功能或者项目的开发,防止老板只将任务交给自己信赖的员工)
  2. 分解任务,了解了极限编程,实现方式有TDD(测试驱动开发)、结对编程、客户现场等,而实现 TDD 的核心是任务分解,将任务分解足够细,足够小,这能表明你已经对如何实现该任务完全了熟于胸,并且足够细小的任务可以帮助我们减少外界的干扰,不会发生编程被打断,需要花很多时间才能重新连接上的问题,因为你的问题足够小,要重新续接上是非常简单的事