IO总结

同步/异步、阻塞/非阻塞、BIO/NIO/AIO 与多路复用

Posted by Ekko on August 14, 2020

这篇笔记的目标是把 I/O 中最容易混淆的几组概念放到同一张图景里:同步和异步、阻塞和非阻塞、BIO/NIO/AIO,以及多路复用。内容重点放在网络 I/O 和 Java 常见实现上,目的是先把概念边界理顺,再去理解框架和源码中的具体写法。

很多资料会把这些词混着用,或者直接把 NIO 等价成“同步非阻塞”、把 AIO 等价成“内核一定真异步”。这种说法在入门阶段有助于建立直觉,但一旦继续深挖,就很容易在操作系统语义、Java API 语义和框架实现语义之间打架。这篇内容的重点就是把这几个层次拆开。

参考资料:

官方文档:java.nio.channels package summaryAsynchronousChannelAsynchronousSocketChannelByteBuffer

系统资料:Linux man page - selectLinux man page - pollLinux man page - epoll

实践参考:JavaGuide - BIO、NIO 和 AIOJava 面试那些事儿多路复用

[TOC]


什么是 I/O

在计算机系统中,I/O 就是输入(Input)和输出(Output)。只要一个系统涉及数据从一个地方流向另一个地方,例如键盘输入、磁盘读写、网络收发、数据库访问、设备通信,都可以放到 I/O 的语境里理解。

针对不同对象,又可以继续细分为磁盘 I/O、网络 I/O、内存映射 I/O、Direct I/O、数据库 I/O 等。Java 讨论 BIO、NIO、AIO 时,最常见的背景其实是网络 I/O,因为高并发服务端最容易在这里暴露出线程数量、系统调用次数和数据搬运成本的问题。

在今天的系统里,I/O 往往就是性能瓶颈的主要来源之一。大量文件处理、大量数据库访问、大量网络连接,都会把瓶颈推向磁盘、网络、内核缓冲区和线程调度,而不是单纯的 CPU 算力本身。也正因为如此,Java 一直在持续完善 I/O 编程模型:从早期流式 BIO,到 JDK 1.4 引入的 NIO,再到 JDK 7 的 NIO.2 异步通道。

无论具体 API 怎么变化,理解 I/O 语义时都可以先抓住一个最核心的事实:一次网络读操作,通常至少包含“等待数据就绪”和“把数据从内核空间拷贝到用户空间”这两个阶段

进程中的 I/O 调用过程可以粗略拆成下面几步:

  1. 应用线程发起读写请求
  2. 操作系统等待数据到达,或者等待设备进入可读/可写状态
  3. 数据先进入内核缓冲区
  4. 操作系统把数据从内核缓冲区复制到用户缓冲区
  5. 应用线程真正拿到数据,继续执行业务逻辑

这几个步骤几乎是理解后文所有概念的基础。后面提到的阻塞、非阻塞、同步、异步,本质上都和这几个阶段里“线程是否等待”“结果如何通知”“谁来完成数据搬运的最后一步”有关。


理解 I/O 的两个阶段

很多概念混淆,根源都在于没有把 I/O 的两个阶段拆开:

  1. 等待就绪: 数据还没到,或者对端还没发,或者网卡/磁盘还没把数据准备好
  2. 复制数据: 数据已经准备好了,但还要从内核缓冲区搬到用户缓冲区

不同 I/O 模型的核心差异,主要就在于这两个阶段里线程怎么参与。

flowchart TB
    A[应用发起 I/O 请求] --> B[等待数据就绪]
    B --> C[数据进入内核缓冲区]
    C --> D[复制到用户缓冲区]
    D --> E[应用真正拿到数据]

如果从这两个阶段来看:

  • BIO 往往是线程从头等到尾
  • 非阻塞 read/write 会让线程在“等待就绪”阶段尽快返回
  • I/O 多路复用会把“等待很多连接是否就绪”这件事集中交给 select/poll/epoll
  • AIO 则是把“完成后通知”这一层进一步交给系统或实现框架

同步和异步

同步:

同步强调的是结果依赖关系。发起方在拿到结果之前,后续处理不能真正完成,必须自己盯着这次调用的结果。

放到 I/O 里,可以把同步理解为:调用方自己负责等待结果、检查状态、拿走结果并继续往下执行

如果用一个更生活化的例子来理解,同步很像“排队到窗口买火车票”。

售票窗口一次只能处理一个人,售票系统真正关心的是购票动作必须一个接一个地完成。至于排队的人是在看手机、聊天,还是做别的事情,并不影响“必须轮到前一个人结束,下一个人才能开始”这个事实。

这个例子说明,同步首先强调的是多个动作之间的先后关系,而不是等待期间每个人具体处于什么状态。

除了这种由于共享资源导致的同步,还存在一种由于逻辑先后顺序导致的同步。

例如,先更新代码,再编译,再打包。这几个动作不一定是因为抢占同一个物理资源才必须串行执行,而是因为后一步本身依赖前一步的结果。如果代码还没有更新完成,编译就无从谈起;如果编译产物还没有生成,打包也无法继续。

例如:

  • 调用 read() 后,线程自己等待读结果
  • 调用 Future.get() 时,线程自己等待异步任务结果
  • 调用方不断轮询某个状态是否完成

同步并不天然等于阻塞,但同步意味着调用方要自己对结果负责。至于是傻等、轮询,还是先做别的事情再回来拿结果,那是阻塞/非阻塞层面的问题。

异步:

异步强调的是结果通知方式。发起方把请求交出去后,不需要自己一直盯着这个过程,等结果准备好之后,由系统或其他机制主动通知。

放到 I/O 里,可以把异步理解为:调用方发起请求后先返回,等完成时由回调、事件、通知机制把结果交回来

如果继续沿用生活中的类比,异步更像“取号后等待通知”。

动作发起之后,后续不需要一直站在窗口前盯着办理过程,而是可以先去处理别的事情。等系统准备好结果之后,再通过广播、短信、叫号屏或其他通知方式把结果交回来。

这个例子里最关键的点,不是“过程中有没有等待”,而是是否必须由发起方自己一直守着结果

例如:

  • CompletionHandler 在异步通道完成时被回调
  • 事件循环收到完成事件后再处理结果
  • 消息、信号、通知把处理结果推回给调用方

同步和异步的关注点,可以概括成下面这张表:

概念 关注点 核心问题
同步 调用方自己拿结果 结果没到之前,后续逻辑怎么继续
异步 完成后如何通知 结果准备好之后,谁来把结果交回来

阻塞和非阻塞

阻塞:

阻塞强调的是当前线程能不能继续往下执行

如果线程发起一个调用后被挂起,直到条件满足才能恢复执行,这就是阻塞。

可以把阻塞理解成“道路已经堵死,车暂时动不了”。

前方条件没有满足之前,当前这辆车只能停在原地;放到程序里,就是当前线程不能继续推进这条执行路径,只能等待系统再次唤醒。

典型情形:

  • 调用 read(),没有数据就睡眠等待
  • 调用 accept(),没有连接就阻塞
  • 调用 epoll_wait(),没有事件就阻塞等待

非阻塞:

非阻塞强调的是当前调用尽快返回,不把线程挂死在原地

如果条件还没准备好,就立即返回一个“暂时不可做”的结果,例如返回 0、返回 EAGAIN/EWOULDBLOCK、或者返回一个未完成的句柄,让调用方稍后再来处理。

继续用交通来类比,非阻塞更像“前方暂时不能通过,但车辆可以先绕开,稍后再回来确认”。

对应到程序里,当前调用不会把线程钉在原地,而是尽快把控制权还给调用方。至于下一步是稍后重试、轮询检查,还是交给事件机制统一处理,那是更上层的策略问题。

阻塞和非阻塞的关注点,也可以概括成下面这张表:

概念 关注点 核心问题
阻塞 线程是否被挂起 当前线程能不能继续干别的事
非阻塞 调用是否立即返回 条件未就绪时,是否必须卡在原地

同步/异步 与 阻塞/非阻塞 的关系

这两组概念很容易被混在一起,但它们并不是同一个维度:

  • 同步/异步关注的是结果如何交付
  • 阻塞/非阻塞关注的是线程在等待期间能不能继续执行

如果直接背定义,通常会越看越绕。更容易理解的方式,是重新回到那两个基础阶段:

  1. 等待数据就绪
  2. 把数据从内核缓冲区复制到用户缓冲区

先只看阻塞和非阻塞

  • 如果线程在“等待数据就绪”这个阶段被挂起,就是阻塞
  • 如果线程发起调用后立刻返回,没有数据就下次再来,就是非阻塞

再看同步和异步

  • 如果调用方需要自己反复检查状态、自己调用 read() 把结果拿回来,这一层更接近同步
  • 如果调用方把请求提交出去后,不需要自己盯着过程,而是等系统在完成后回调或通知,这一层更接近异步

按照这个思路,再去看几种常见情况就容易很多:

场景 等待数据阶段 拷贝数据阶段 更贴近的语义
阻塞 read() 线程阻塞等待 线程继续参与拷贝 同步阻塞
非阻塞 read() + 轮询 没数据立即返回 真正读时仍要自己拿数据 同步 + 非阻塞
select/poll/epoll + read() 阻塞在复用器上等就绪 就绪后自己调用 read() 同步 + 多路复用
AIO/完成回调 提交后立即返回 由系统或实现完成后通知 异步,通常也表现为非阻塞

这里最容易混淆的一点是:

  • 非阻塞 I/O 不等于“线程完全不参与 I/O”
  • 同步 I/O 也不等于“所有阶段都必须一直睡死在那里”

以非阻塞 read() 为例:

  1. 第一次调用时,如果数据还没到,立即返回 EAGAIN
  2. 线程没有被挂起,所以这是“非阻塞”
  3. 但什么时候再读、什么时候真正把数据取走,仍然要调用方自己负责
  4. 因此它在结果交付语义上依然更接近同步

很多文章会把“同步非阻塞”直接一票否决,这种说法在入门阶段容易形成误导。更稳妥的说法是:在操作系统经典网络 I/O 模型里,人们通常直接说“非阻塞 I/O”或“I/O 多路复用”;但如果从更一般的结果交付语义去看,同步和非阻塞并不是绝对互斥。

所以,理解这几组术语时,关键不是机械背定义,而是先问清楚下面三个问题:

  1. 当前线程是否被挂起
  2. 调用方是否需要自己盯着结果
  3. 结果完成后是谁来通知下一步逻辑

BIO、NIO、AIO 基本定义

先给出一个最常见、也是最适合 Java 入门阶段使用的理解框架:

  • BIO(Blocking I/O): 以阻塞式流读写为代表,一个线程通常绑定一个连接或一个 I/O 操作
  • NIO(New I/O): Java 1.4 引入的新 I/O API,核心能力包括 ChannelBufferSelector;在网络编程里,最常讨论的是“非阻塞通道 + Selector 多路复用”
  • AIO(Asynchronous I/O): Java 7 的 NIO.2 异步通道 API,调用方通过 FutureCompletionHandler 获取完成结果

需要注意两点:

  1. NIO 本身是一个API 体系,不等于“只存在一种固定 I/O 模型”
  2. AIO 是 Java 提供的异步编程接口语义,但底层是否由操作系统原生异步完成,依赖平台和 JDK 实现

下表可以先建立一个总览:

Java 语境 更贴近的底层理解 典型特征
BIO 阻塞 I/O 一个线程在单个 I/O 上等待
NIO(非阻塞通道 + Selector) 非阻塞 I/O + I/O 多路复用 一个线程管理多个连接就绪事件
AIO 异步完成通知模型 提交后由 Future 或回调接收结果

BIO(Blocking I/O)同步阻塞I/O

这是最基本、也最符合直觉的一种 I/O 方式。发起一次读写后,线程通常会一直等到这次操作完成,再继续处理下一步逻辑。

它的优点是:

  • 编程模型简单,顺序思维强
  • 调试和排查问题相对直接
  • 并发量不高时,完全够用

它的缺点也很明显:

  • 一个线程通常同时只能盯住一个连接或一个阻塞点
  • 大量空闲连接会占着线程不放
  • 并发量上来以后,线程切换、栈内存占用和上下文调度成本都会明显放大

所以 BIO 并不是“过时不能用”,而是更适合:

  • 连接数不高
  • 业务逻辑远重于 I/O 等待
  • 对开发简单性要求高于极致并发
  • 使用线程池后仍能把线程数量控制在合理范围内

如果是典型的高并发 Web/TCP 服务端,BIO 往往很快就会遇到线程数膨胀的问题。


NIO (New I/O) 与非阻塞、多路复用

关于 NIO,经常有两个容易混淆的点:

  1. NIO 的官方含义是 New I/O,不是 Non-Blocking I/O
  2. Java NIO 这个 API 体系既包含 Buffer/Channel,也包含 Selector;讨论高并发网络模型时,说的往往是“非阻塞通道 + Selector”

NIO 的核心目标是解决 BIO 在大并发场景下“一个连接一个线程”的资源浪费问题。

在 BIO 模型中,如果需要同时处理很多连接,通常就要开很多线程。

在 NIO 模型中,可以把大量连接注册到 Selector 上,由一个或少量线程统一等待“哪些连接已经就绪”,只有真正可读、可写、可连接时,才对对应的 Channel 做实际处理。这背后用到的核心思想,就是 I/O 多路复用

如下图所示,NIO 的编程模型不再直接围绕 InputStream/OutputStream 展开,而是围绕 ChannelBuffer 展开:

NIO方式拷贝文件.png

通过比较 New I/O 的使用方式可以发现,新 I/O 操作不再只面向 Stream,而是引入了更明确的 ChannelBufferSelector 三层抽象。

Channel:

Channel 通常翻译为“通道”。它和传统 IO 里的 Stream 处于相近的抽象层级,但 Channel 往往是双向的,既可以读,也可以写。

NIO 中常见的 Channel 包括:

  • FileChannel:文件 I/O
  • DatagramChannel:UDP
  • SocketChannel:TCP Client 侧
  • ServerSocketChannel:TCP Server 侧监听连接

Buffer:

Buffer 是 NIO 中非常重要的一层,用来承接“数据放在哪里、从哪里读、往哪里写”。

常见 Buffer 实现包括:

  • ByteBuffer
  • CharBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

其中最常用的是 ByteBuffer,网络编程里绝大多数场景最终还是围绕字节缓冲区展开。

Selector:

Selector 是 NIO 相对于 BIO 实现多路复用的关键。多个 Channel 可以注册到同一个 Selector 上,然后由一个线程调用 select() 统一等待事件。

这个等待本身通常是阻塞的,但阻塞的是“等待哪几个连接就绪”这件事,而不是对某一个连接单独阻塞读写。这样就能把“等待很多连接”的成本集中处理,从而避免大量线程空转。

对于低流量但连接数很多的场景,例如聊天服务器、网关、代理服务,Selector 会非常有价值。

NIO 的工作方式

可以把 NIO 的经典网络编程方式概括成下面这条链路:

flowchart LR
    A[多个 SocketChannel] --> B[注册到 Selector]
    B --> C[select 等待就绪事件]
    C --> D[拿到 ready keys]
    D --> E[按事件类型处理 accept/read/write]

Selector 负责解决的是“哪些连接值得现在去处理”,Channel 和 Buffer 负责解决的是“真正怎么读、怎么写、数据放在哪里”。

补充DirectByteBuffer 与 HeapByteBuffer 的区别:

ByteBuffer 分配内存的两种方式:

  • HeapByteBuffer:内存位于 JVM 堆上,本质上可以理解为对 byte[] 的包装

  • DirectByteBuffer:内存位于堆外(native memory / off-heap),JVM 会尽量让底层 native I/O 直接操作这块内存,以减少额外中间拷贝

为什么不直接使用 DirectByteBuffer,还要来个 HeapByteBuffer?

原因在于 DirectBuffer 不是“只会更快,没有代价”。它的特点通常是:

  • 分配和释放成本通常比堆内 Buffer 更高
  • 内存不在 Java 堆里,排查内存占用时更容易被忽视
  • 更适合大块、长生命周期、频繁参与 native I/O 的缓冲区

需要特别注意的是:

  1. DirectByteBuffer 的堆外内存释放依赖 JVM 对象生命周期和清理机制,不适合随手大量创建小对象后指望它“立刻自动回收”
  2. 直接内存有上限,通常受 -XX:MaxDirectMemorySize 影响
  3. 在常见 HotSpot 实现中,如果没有显式指定 -XX:MaxDirectMemorySize,实际可用上限通常会和堆最大值同量级,而不是简单理解成固定 64M

所以,DirectByteBuffer 适合的是“能明显降低拷贝成本”的场景,而不是“只要是 NIO 就默认全部上 DirectBuffer”。

这里再来看一个 NIO 模型下的 TCP 服务器的实现,通过代码可以看到 Selector 正是 NIO 模型下 TCP Server 实现 IO 复用的关键,请仔细理解下段代码 while 循环中的逻辑,见下图:

NIO模型实现TCP服务器.png


AIO (Asynchronous I/O) 与异步通道

Java AIO 通常指的是 JDK 7 引入的 NIO.2 异步通道能力,例如:

  • AsynchronousFileChannel
  • AsynchronousServerSocketChannel
  • AsynchronousSocketChannel

异步通道的结果获取通常有两种形式:

  1. 返回 Future
  2. 提供 CompletionHandler 回调

从编程模型上看,AIO 相对于 NIO 的区别在于:

  • NIO 更常见的写法是“调用方通过 Selector 观察就绪事件,再主动发起读写”
  • AIO 更像是“调用方先把操作提交出去,完成后再通过回调或 Future 获取结果”

但这里有一个非常重要的边界:Java AIO 的 API 语义是异步完成通知;底层是否等价为操作系统原生的真正异步 I/O,需要看平台和实现。

更稳妥的理解方式是:

  • 在 Windows 上,底层更容易映射到 IOCP 这类完成通知模型
  • 在 Linux/Unix 上,JDK 的具体 provider 可能结合线程池、内核通知机制等方式来实现异步接口语义
  • 因此不能简单地说“Java AIO 在所有平台上都一定直接对应底层真异步 I/O”

这也是为什么在 Java 高并发网络编程里,生产实践中更常见的是基于 NIO/Selector 的框架,例如 Netty;AIO API 虽然存在,但不是所有场景都会把它作为默认首选。


从经典网络 I/O 模型再看

如果回到操作系统层面,经典网络 I/O 模型通常会分成下面几类:

模型 等待数据就绪 拷贝数据到用户空间 线程表现 典型说明
阻塞 I/O 阻塞 阻塞 线程从头等到尾 最传统的 read()
非阻塞 I/O 不阻塞,没数据立即返回 真正开始拷贝时仍需要参与 线程反复重试 O_NONBLOCK + 轮询
I/O 多路复用 阻塞在复用器上 真正读写时仍要调用 read/write 一个线程等很多连接 select/poll/epoll
信号驱动 I/O 就绪后由信号通知 读数据时仍要主动调用 工程里不算最主流 SIGIO
异步 I/O 不阻塞 由系统完成后通知 调用方提交后可继续执行 completion-style

如果只看“线程是不是卡住”,可以先这样理解:

  1. 阻塞 I/O: 线程自己等数据,数据到了以后还是线程自己把数据拿走
  2. 非阻塞 I/O: 线程先去问一句“数据到了没”,没到就立刻返回,过一会儿再来问
  3. I/O 多路复用: 不再逐个连接傻问,而是先让 select/poll/epoll 统一等,等到“某些连接已经好了”再去读
  4. 异步 I/O: 先把请求交出去,数据准备和拷贝都完成后,再把结果通知回来

所以这几种模型的关键差异,可以概括为:

  • 非阻塞 I/O 解决的是“等待数据时,线程是否必须原地卡住”
  • I/O 多路复用 解决的是“很多连接同时等待时,是否必须一个连接占一个线程”
  • 异步 I/O 解决的是“结果完成后,是否还需要调用方亲自去把数据取回来”

这也是为什么:

  • 非阻塞 I/O 不是“数据已经拷到用户空间后才通知线程”,那样的语义更接近异步 I/O
  • I/O 多路复用 本身仍然属于同步模型,因为应用线程最终还是要自己调用 read/write
  • 异步 I/O 才更接近“内核或实现把事情做完后,再把结果交给应用”

多路复用

I/O 多路复用(I/O multiplexing)是一种非常重要的同步 I/O 模型。它的核心不是“替应用把数据读完”,而是让一个线程可以同时监视多个文件描述符,知道哪些连接已经具备读写条件

“多路”指的是多个连接、多个 socket、多个文件描述符;“复用”指的是把原本可能需要很多线程分别等待的工作,集中复用到一个或少量线程上完成。

IO多路复用.png

它的本质可以理解为:不再让每个连接各自睡一个线程,而是让一个线程先等“事件通知”,只在连接真正就绪时再处理它。

发明它的原因,就是尽量提高高并发服务器的吞吐能力,减少无意义的线程阻塞和上下文切换。

IO多路复用2.png

在同一个线程里,通过“先知道谁准备好了,再处理谁”的方式,同时管理多个 I/O 流。

IO 多路复用解决的问题:

应用程序经常要同时处理来自多条事件流的事件。例如操作系统要处理键盘、鼠标、中断;Nginx、Netty 这类服务端则要同时处理大量客户端连接。

CPU 单核同一时刻只能做一件事。最直接的办法当然是多线程或多进程,让操作系统调度不同执行流轮流占用 CPU。但这会带来额外成本。

但凡事都是有成本的。线程/进程也一样,有这么几个方面:

  • 线程/进程创建成本
  • CPU 切换不同线程/进程的成本(Context Switch)
  • 多线程的资源竞争

I/O 多路复用可以在单线程或少量线程中处理多个事件流,因此它解决的本质问题是:用更少的线程资源完成更多连接的等待与调度工作


IO多路复用的三种实现方式:select、poll、epoll

select 是第一个实现 (1983 左右在 BSD 里面实现):

select 很早就被实现出来了,但也很快暴露出一些问题:

  • 需要反复把待监视描述符集合从用户态传入内核态
  • 返回后仍然要遍历描述符集合,找出到底哪些 fd 就绪
  • fd_set 通常存在 FD_SETSIZE 限制,很多实现里默认是 1024
  • 当连接数很大时,线性扫描成本明显

14年以后(1997年)其他人实现了 poll, poll 修复了 select 的很多问题:

  • poll 去掉了 select 那种固定 1024 上限的典型限制
  • poll 使用 pollfd 数组表达关注事件,接口设计上更灵活一些

但是 poll 依然要在每次调用时把关注列表交给内核,再由内核遍历,因此在大量连接场景下,复杂度问题仍然存在。

5年以后, 在2002, Davide Libenzi 实现了epoll:

epoll 是 Linux 上 I/O 多路复用的重要实现。它和 select/poll 的关键差异在于:

  • 关注列表保存在内核中,不需要每次完整重复传入
  • 内核维护 ready list,epoll_wait 返回的是就绪事件集合
  • 更适合大量连接、少量活跃连接的高并发场景
  • 支持 LT(水平触发)和 ET(边沿触发)两种模式

需要注意的是,epoll_wait() 本身仍然是一个阻塞等待事件的系统调用,所以它属于同步 I/O 多路复用模型,并不等价于“异步 I/O”。

可以把三者的差异总结成下面这张表:

机制 描述符数量限制 每次调用是否重复传入全部 fd 就绪结果获取方式 适用场景
select 常见实现里限制明显 返回后自己遍历 连接数较少、兼容性优先
poll 无固定 1024 限制 返回后自己遍历 中等规模、可移植性要求较高
epoll Linux 下可扩展性更好 返回 ready list 高并发网络服务器

epoll 的缺点也很明确:它是 Linux 特有机制,不具备跨平台通用性。
在 BSD/macOS 上更常见的是 kqueue,在 Windows 上则是 IOCP。


BIO、NIO、AIO 的关系总结

最后把全文最容易混淆的地方再收束一下:

  1. BIO、NIO、AIO 是 Java 语境中的 API 和编程模型术语
  2. 阻塞/非阻塞、同步/异步、select/poll/epoll 是更底层的 I/O 语义或操作系统机制
  3. Java NIO 在网络编程里最常见的落地方式,是非阻塞通道配合 Selector 实现 I/O 多路复用
  4. Java AIO 表达的是异步完成通知接口,不应简单等同于所有平台上的“内核原生真异步 I/O”

如果只需要先记一条最实用的经验,可以概括为:

  • 连接少、实现简单优先,用 BIO 也完全可以
  • 高并发网络服务端,通常优先考虑基于 NIO/Selector 的方案
  • AIO 更适合理解异步完成式接口模型,但 Java 生产实践里并不一定比 NIO 更常见

到这里,再去看 Netty、Tomcat、Redis、Nginx 这类高并发框架或中间件的网络模型,就更容易把“为什么要事件循环、为什么要 Reactor、为什么要多路复用”这些问题串起来。