Rust通过绑定使用 io_uring本文翻译自 https://www.thespatula.io/rust/rust
本文翻译自 www.thespatula.io/rust/rust_i…
介绍
这是一个系列的一部分,我正在努力从零开始构建一个游戏。您可以在这里找到所有内容的存储库,这里是我执行WebSockets的系列的最后一部分。
回溯一下,如果你错过了之前的任何或所有内容,我试图只使用Rust标准库构建所有内容。到目前为止,这并不太困难,但后来我决定我真的希望我的WebSocket服务器是可扩展的,这就是一切都走到了一边。
侧身
不是电影那种侧身,当我想到它我得到一个坏的味道在我的嘴里。这个侧面是意识到,如果我想只使用标准库来执行JavaScript,我将需要在epoll或io_uring之上构建它。这些API允许在Linux上处理输入和输出操作,例如文件和网络。mio库是建立在epoll之上的,而最受欢迎的aprc库时雄也是建立在Mio之上的。Mio本身构建在libc上,使用系统调用与epoll进行通信,epoll是Linux内核的一部分。你可以看到他们使用系统调用! macro(用于Linux),可在此处找到。
我们的起点和终点
这对我们来说意味着,如果我们想要apec,我们需要使用epoll并构建一个最小的mio,在此基础上我们将构建一个最小的时雄apec运行时。或者,我们可以选择另一个API io_uring,并在此基础上进行构建。对我来说,这听起来更有趣,因为io_uring是闪亮的和新的。
公平地说,使用io_uring的决定花了它和epoll的一点时间,虽然后者肯定有更多的代码示例和广泛的文档,但前者可能是未来的发展方向。但在我们开始之前,让我们先来看看这两个。
To epoll or liburing?
在Linux上,创建JavaScript运行时的可靠方法是使用epoll。虽然时雄的tokio-uring是建立在io_uring之上的,但大多数人使用的时雄库是建立在epoll之上的。但让我们倒回一个时刻,并得到我们的轴承。
您可能听说过epoll、kqueue、IOCP或io_uring,但它们是操作系统处理并发I/O操作的核心,例如阅读和写入文件以及TCP连接。如果您正在处理CDN、文件服务器、数据库系统等,那么当访问文件或数据时,它可能会使用这些队列之一。当你打开一个网站的连接并请求一个页面时,也会发生同样的事情。也就是说,它并不总是必须使用这些队列,但在高度并发的阅读和写数据的情况下可能会使用。
在Linux上,从网络连接到实际文件的所有内容都与文件描述符相关联,由于我们关注的是Linux,因此我们将从epoll和以下定义开始:
epoll API(特定于Linux)允许应用程序监视多个文件描述符,以确定哪些描述符准备好执行I/O。该API旨在更有效地替代传统的select()和poll()系统调用。
引自lwn.net
它是怎么做到的?阅读这篇文章的简介给出了一个好主意。他们也有一个方便的图表,我重新做了,因为我需要美学的一致性:
从这里重新绘制
要分解这个,会发生什么:
- ·创建一个内核级epoll实例来保存我们的文件描述符。
- ·将文件描述符添加到我们的兴趣列表中。
- ·操作系统监视这些I/O。
- ·询问操作系统是否准备好了(如果没有,则阻止)。
所以,epoll,作为操作系统的一部分,是软件,和所有软件一样,如果某个东西成功了,那么人们总是想创建自己的,新的和改进的版本。输入io_uring,这是一个API,它可以做与epoll类似的事情,尽管我前面的句子暗示了我的尖刻,但它实际上在许多方面都是一种改进。
有许多不同之处,但两个是io_uring是异步的,而不是事件驱动的,这意味着它不会阻塞上面的步骤(4),并且它还允许重复操作。这个插件可以减少系统调用的数量,也就是对操作系统的调用,如果你不知道的话,系统调用是昂贵的,而我又不是有钱人。
当然,还有许多其他的差异,从io_uring的队列与epoll的列表的不同,以及每个API可以做的事情的广度和深度,但这是我们将在构建P2P运行时的过程中展开的。
在我们离开epoll去寻找闪亮的新事物之前,我建议看看这篇文章,它有一个很好的解释和epoll的工作示例。理解它是如何工作的,以及为什么它以这种方式工作,将帮助你更好地欣赏它。
但是等等,您可能会说,如果您正在构建WebSocket服务器,请不要离开epoll,因为它仍然可能比io_uring具有更好的性能。是的,你可能是对的,但阅读评论,似乎这些问题将得到解决。此外,io_uring比epoll年轻得多,我只能看到它随着开发的推进而变得更好。另外,我可以学到一些新的和很酷的东西,我可以在晚宴上炫耀,这是最重要的事情。
本文的其余部分。
以下是我们在本文中最终要做的:
- 1. 从源代码安装liburing。
- 2. 使用bindgen生成绑定。
-
- 修复(2)的bug。
-
- 建立一个简单的安装程序,我们可以。
现在,如果你熟悉bindgen,你可能会认为我要在我的项目中添加一个crate,这意味着我不仅仅像我之前宣传的那样使用标准库。通常情况下,这是真的,如果我的临时变通方法在长期内不奏效,我可能最终会这样做,但现在我只打算使用bindgen-tool,然后调用它。作弊?也许吧,但我们的TOML将像塞缪尔·L·哈钦森(Samuel L.杰克逊的圆顶(我向塞缪尔L。杰克逊)。
安装Liburing
如果你想自己沿着走,你需要满足几个先决条件:
- ·使用Linux(我使用基于Mint 21.2 /Ubuntu的)
- ·使用最新的内核(5.1之后的版本,但最好是6.x)
您可以使用以下命令检查内核:
uname -r
或者,使用一个Linux容器,确保它具有Web访问权限,并使用ssh。解决了这些问题之后,让我们开始安装liburing,这个库将帮助我们使用io_uring。我们要做的第一件事是检查它是否已经安装:
ldconfig -p | grep liburing
如果你得到了一些东西,你可以尝试使用这些现有的库,或者继续下一节,我们从源代码安装最新的库。我发现这是最可靠的,因为默认的apt仓库有一个旧版本。如果需要的话,你也可以参考原始资料。
来源
如果您已经安装了库,但希望安装最新版本,则可以执行以下操作来删除它:
sudo apt remove liburing-dev liburing2
sudo apt autoremove
ldconfig -p | grep liburing
这里的最后一行只是证明它已经消失了。如果在运行时您仍然收到某些内容,那么您可能需要转到文件夹 /usr/lib和 /usr/include并手动删除所有与liburing关联的 .so和头文件。如果您使用的是不同风格的Linux,那么您的文件也可能位于另一个文件夹中,因此请确保查看ldconfig输出。
接下来,确保你有构建工具,以便从源代码编译:
sudo apt install build-essential
完成后,您可以克隆存储库,配置和制作。在repo中有更具体的方向,但我摆脱了以下情况,它工作得很好:
git clone https://github.com/axboe/liburing
cd liburing
./configure
make
sudo make install
sudo ldconfig
ldconfig的最后一行是更新共享库(.so - shared object)缓存并使其识别liburing的存在。在它运行之后,你可以通过转到 /usr/lib和 /usr/include来检查它是否都在那里,或者运行以下命令:
ls /usr/lib | grep "liburing"
ls /usr/include | grep "liburing"
您应该在每个文件夹中看到多个文件。如果没有,您需要检查它是否在另一个文件夹中,或者在此过程中沿着错误。
生成绑定
我们将使用bindgen,这是一个命令行工具:
自动生成FFI绑定到C和C++库。
如果你不熟悉FFI(外部函数接口),这是一种调用其他语言库的方法。很多东西都是用C/C++写的,并且已经过广泛的测试,所以我们不是在它的基础上重新发明轮子。像Python这样的语言大量使用C库和Numpy和Pandas这样的包。这主要是出于性能原因,当涉及到CPU密集型任务时,Python会调用它们来进行大量繁重的工作。
从本质上讲,我们最终想要的是一些Rust函数的shell,它们包装了底层的库函数。当一切都说了和做了之后,它们会看起来像这样:
extern "C" {
pub fn sendmsg(
__fd: ::std::os::raw::c_int,
__message: *const msghdr,
__flags: ::std::os::raw::c_int,
) -> isize;
}
让我们安装bindgen并开始:
cargo install bindgen-cli
一旦我们有了这个,我们将以两种方式生成are绑定:
- ·手动,在命令行中,然后将它们复制到我们的项目中。
- ·以编程方式,作为构建过程的一部分。
后一种方法是正确的,但我们将使用第一种方法,这样我们就可以看到它是如何工作的。
手工装订
你需要做的第一件事是在项目的根目录中创建一个名为wrapper. h的文件。名称并不重要,您可以将其称为headers. h或任何您想要的名称。您的项目文件夹看起来像这样:
├── Cargo.lock
├── Cargo.toml
├── wrapper.h
├── src
│ └── main.rs
└── target
在wrapper.h中,我们将列出我们想要绑定的liburing C头文件:
#include "/usr/include/liburing.h"
还有其他的头文件,但是bindgen应该递归地检查所有也包含在liburing. h中的文件,比如io_uring. h和barrier. h。添加wrapper.h后,现在可以从项目文件夹中运行bindgen:
bindgen wrapper.h –output bindings.rs
运行后,您可以打开bindings.rs,它应该在wrapper.h旁边,并看到大约10k行代码。不幸的是,它不会捕捉到解放的一切。你可以通过打开原始的liburing. h并找到这个函数来检查这一点:
IOURINGINLINE void io_uring_cq_advance(struct io_uring *ring, unsigned nr)
你不会在生成的文件中找到它,原因是它是一个内联函数,由IOURINGINLIEN表示。我们需要显式地告诉bindgen包含内联函数。你可能想知道为什么它不会默认绑定它们?根据文档:
这些函数通常不会在对象文件或共享库中结束,我们可以可靠地链接到符号,因为它们会内联到每个调用站点。因此,我们不生成到它们的绑定,因为这会创建链接错误。
在我们的例子中,我们不应该有这个问题,或者至少到目前为止我还没有,所以我们将通过运行以下命令来强制bindgen包含它们:
bindgen wrapper.h --experimental --wrap-static-fns --output bindings.rs
现在,您可以搜索bindings.rs,您应该会发现包含了io_uring_cq_advance函数。要使用这些绑定,您可以将bindings.rs文件移动到src/ 文件夹中,然后在main.rs中添加:
pub mod bindings;
use bindings::*;
fn main() {
// Nothing here yet
}
在那之后,你会得到大量关于命名的错误。要抑制这些,请在bindings.rs文件的顶部添加以下内容:
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
虽然在使用这些函数时,您不会通过编辑器获得任何可见的错误,但当您尝试构建时,您将获得它们,这是因为您尚未链接实际的库。我建议尝试构建,这样你就可以看到错误是什么样子的。
要解决此问题,请在项目文件夹中与wrapper.h一起创建一个build.rs文件,并向其中添加以下内容:
fn main() {
println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-lib=dylib=uring");
}
build.rs脚本,你可以在这里阅读更多,是一个专门命名的脚本,运行它是为了链接到外部库,如liburing。我找不到一个令人满意的使用println的理由 ! 但是似乎确实有围绕构建过程和使用打印的选择的讨论和RFC。
这些print语句将 /usr/lib添加到搜索路径,然后链接到liburing。奇怪的是,print语句实际上说的是链接到uring,而库被称为liburing。Linux上的约定是在libraries前面加上lib,所以我想,就照这样吧。
print语句的dylib部分意味着我们是动态链接而不是静态链接,这意味着如果我们要按原样分发它,其他人需要安装这个库。至此,一切就绪,您应该能够运行cargo build, 而只会出现一些关于未使用的导入的警告。
注意:此时您将能够使用非内联的函数和类型,但内联函数仍可能失败。我们将在下一节中解决这个问题。
构建过程
如前所述,有一种更好的方法,那就是扩展我们build.rs脚本。使用构建脚本而不是手动方法的原因归结为:
- ·如果我们更新库,它会自动更新绑定。
- ·我们可以调整脚本以适应不同的平台(OS)。
还有其他原因,但我们只能说,就目前而言,这是一个更可持续的方法,并继续与节目。
Bindgen
让我们首先修改build.rs脚本,如下所示:
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-lib=dylib=uring");
println!("cargo:rerun-if-changed=wrapper.h");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
// Generate bindings using command-line bindgen
let bindgen_output = Command::new("bindgen")
.arg("--experimental")
.arg("--wrap-static-fns")
.arg("wrapper.h")
.arg("--output")
.arg(out_path.join("bindings.rs"))
.output()
.expect("Failed to generate bindings");
if !bindgen_output.status.success() {
panic!(
"Could not generate bindings:\n{}",
String::from_utf8_lossy(&bindgen_output.stderr)
);
}
}
虽然前两行是熟悉的,但我们添加了相当多的内容,但如果你仔细观察,你会发现除了out_path之外,bindgen语句正在做我们之前在命令行中做的事情。让我们看看不熟悉的部分。
- 首先,有out_path,这是一个由cargo在编译时创建的环境变量。它告诉我们构建的位置,我们将其用于bindings.rs。
- ·第二,Command语句运行我们的bindgen语句,就像它在命令行一样,然后如果失败就会抛出异常。
- ·第三,如果语句返回为不成功,我们会吐出一条错误消息,沿着从Command返回的Output对象中提供的任何错误。
不过,这并不是100%有效,因为那些内联函数仍然会给我们带来困难。我们在这里所做的就是采用命令行版本并在构建脚本中展开它。
内联函数
这就是事情变得有点困难的地方,好像他们还没有达到这一点。当我们运行**-wrap-static-fns时,它将为我们创建内联函数的包装器,但它们是用C完成的,并输出到extern.c**文件。实际上,如果您自己在 /tmp/bindgen中找到此文件并将其删除,则可以使用前面部分中的命令重新生成它:
bindgen wrapper.h --experimental --wrap-static-fns --output bindings.rs
由于我们不能在程序中直接使用extern.c,我们需要编译它,以便我们可以链接到对象(so)文件。最简单的方法是将cc crate放入crate中,然后执行以下操作:
cc::Build::new()
.file(&extern_c_path)
.include("/usr/include")
.include(".") // To find wrapper.h in current directory
.compile("extern");
但是,这样做会破坏我们毫发无损的cargo.toml,所以我们将以一种更复杂的方式使用gcc自己来做:
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rustc-link-search=native=/usr/lib");
println!("cargo:rustc-link-lib=dylib=uring");
println!("cargo:rerun-if-changed=wrapper.h");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
let extern_c_path = env::temp_dir().join("bindgen").join("extern.c");
// … previous bindgen code goes here
// Compile the generated wrappers
let gcc_output = Command::new("gcc")
.arg("-c")
.arg("-fPIC")
.arg("-I/usr/include")
.arg("-I.")
.arg(&extern_c_path)
.arg("-o")
.arg(out_path.join("extern.o"))
.output()
.expect("Failed to compile C code");
if !gcc_output.status.success() {
panic!(
"Failed to compile C code:\n{}",
String::from_utf8_lossy(&gcc_output.stderr)
);
}
// Create a static library for the wrappers
let ar_output = Command::new("ar")
.arg("crus")
.arg(out_path.join("libextern.a"))
.arg(out_path.join("extern.o"))
.output()
.expect("Failed to create static library");
if !ar_output.status.success() {
panic!(
"Failed to create static library:\n{}",
String::from_utf8_lossy(&ar_output.stderr)
);
}
// Tell cargo where to find the new library
println!("cargo:rustc-link-search=native={}", out_path.display());
println!("cargo:rustc-link-lib=static=extern");
}
这就是我们正在做的:
- ·定义extern.c文件的路径。
- ·Bindgen运行并创建绑定。
- ·包装的内联函数放在 **extern.c ** 中。
- ·我们编译包装器。
- ·从目标文件创建静态库。
- ·告诉Rust在哪里可以找到库。
我相信步骤(1)很容易理解,我们已经完成了上面的(2),所以我们将从(4)开始,并将选项传递给编译器:
- ·-c:编译为对象。
- ·-fPIC:参见链接。
- -I/usr/include:在指定路径中查找头文件。
- ·-I:在当前位置查找头。
剩下的代码处理我们的输出路径,这应该很容易理解。现在,我并不完全确定fPIC的用途,但我觉得它用于动态链接之外并不重要。
在第二部分中,我们创建静态库,我们使用ar,这是一种用于库的归档工具。我们传递给它一个命令,它实际上是四个命令:
- ·c:创建。
- ·r:插入替换。
- ·u:只插入更多当前文件。使用R。
- ·s:将目标文件写入存档。
您可以在这里阅读这些命令的细微差别,但总结一下,它正在构建一个存档( .a),然后是一个静态链接的对象文件( .o)。此时,您应该能够无错误地编译。
基本日志设置(_O)
现在我们已经创建了绑定,我们将做一个快速而粗略的演示,在下一篇文章中,我们将构建一些功能更全的东西。首先,您需要将以下内容添加到您main.rs:
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#[cfg(not(rust_analyzer))]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
use std::io;
use std::mem::zeroed;
use std::ptr::null_mut;
初始宏将忽略我们不关心的错误。后面的两行有点奇怪。它们所做的是确保我们连接到我们的绑定,你可以把它想象成一个pub mod语句,但是要进入build文件夹而不是src来查找文件。在本例中,它使用了前面提到的OUT_EXPERT环境变量来查找bindings.rs文件。这两行中的第一行--include-line上面的宏--只是一种关闭可能出现的错误的方法,这取决于您的编辑器。如果没有,它仍然可以编译得很好,但是你可能会在编辑器中看到一个错误。
如果你没有错误,那么我们已经成功地创建了io_uring的绑定,并准备创建基本的示例,但在此之前,我们应该看看io_uring实际上是什么,以及为什么我们需要它。
好吧,什么事?
此时,您可能已经意识到,我们一直在忙碌安装库,以至于我们从未越过基本的学习来理解API是如何工作的。我建议你阅读这篇文章和这篇文章,以获得更深入的理解。现在,我将从第二个链接中借用这个图表,以便给出快速描述:
提交和完成队列(从这里重新创建)
使用io_uring, 你有一个提交和完成队列,这类似于epoll的兴趣和就绪列表,但有一个主要的区别。使用epoll时,列表由内核在内核空间中维护,而队列则位于使用io_uring的共享内存空间中。这个模型的好处是您不需要进行那么多的系统调用,特别是在更新队列中的项目状态等时。这意味着总体上上下文切换较少。
io_uring的过程基本相同,至少在概念上是如此。您可以向提交队列中添加提交队列条目或SQE,并从完成队列中获取已完成的工作,就像在epoll中添加和检索项一样。通常,这些条目将涉及等待I/O,无论是文件读/写还是网络流量,尽管您也可以将其用于其他事情,为了避免已经很长的文章变得太长,我不会在这里讨论。
添加条目后,您可以选择是使用io_uring_wait_cqe阻塞并等待完成队列条目(CQE),使用超时等待,还是使用io_uring_peek_cqe轮询。无论你做什么,你最终都会得到一个超时的响应,并继续添加和获取项目。
这是一个关于正在发生的事情的手波浪式描述,还有更多的内容,我们将在以后的文章中看到,但现在上面的图像提供了一个很好的可视化。
回到我们的代码
对于最后一点,我们将使用绑定创建一个简单的队列,并向其抛出一些东西,然后返回一些东西。我们将执行以下操作:
- ·设置日志并创建队列。
- ·提交noop(无操作)。
- ·等待它完成。
- ·退出队列。
让我们先来看看我们的main函数的流程:
fn main() -> io::Result<()> {
let queue_depth: u32 = 1;
let mut ring = setup_io_uring(queue_depth)?;
println!("Submitting NOP operation");
submit_noop(&mut ring)?;
println!("Waiting for completion");
wait_for_completion(&mut ring)?;
unsafe { io_uring_queue_exit(&mut ring) };
Ok(())
}
在main.rs中,我们首先设置了这个队列:
let queue_depth: u32 = 1;
let mut ring = setup_io_uring(queue_depth)?;
查看函数setup_io_uring,我们有以下内容:
fn setup_io_uring(queue_depth: u32) -> io::Result<io_uring> {
let mut ring: io_uring = unsafe { zeroed() };
let ret = unsafe { io_uring_queue_init(queue_depth, &mut ring, 0) };
if ret < 0 {
return Err(io::Error::last_os_error());
}
Ok(ring)
}
我们获取队列的深度,在main中设置为1,然后返回一个io_uring类型。这个类型来自我们的绑定,看起来像这样:
pub struct io_uring {
pub sq: io_uring_sq,
pub cq: io_uring_cq,
pub flags: ::std::os::raw::c_uint,
pub ring_fd: ::std::os::raw::c_int,
pub features: ::std::os::raw::c_uint,
pub enter_ring_fd: ::std::os::raw::c_int,
pub int_flags: __u8,
pub pad: [__u8; 3usize],
pub pad2: ::std::os::raw::c_uint,
}
这是通过io_uring_setup函数填充的,该函数从io_uring_queue_init中调用。你可以在这两个文件夹中找到。这将把提交队列(sq)的大小设置为1,然后自动把完成队列(cq)设置为两倍,即2。完成队列的大小需要大于提交队列的大小。还要注意,我们已经将参数清零,将它们设置为默认值。
我们将在整个过程中使用不安全的包装器,这是因为我们正在呼吁非Rust世界,并且无法保证。在这种情况下,我们必须信任库的作者。
回到main,我们有一个环变量,它实际上是由两个环组成的(提交和完成),我们将把它传递给submit_noop,它执行以下操作:
fn submit_noop(ring: &mut io_uring) -> io::Result<()> {
unsafe {
let sqe = io_uring_get_sqe(ring);
if sqe.is_null() {
return Err(io::Error::new(io::ErrorKind::Other, "Failed to get SQE"));
}
io_uring_prep_nop(sqe);
(*sqe).user_data = 0x88;
let ret = io_uring_submit(ring);
if ret < 0 {
return Err(io::Error::last_os_error());
}
}
Ok(())
}
现在的情况是:
- 1. 从初始化的环中获取提交队列。
-
- 检查它是否存在(非空)。
- 3. 使用io_uring_prep_nop创建一个noop。
- 4. 将一些数据附加到它的一个字段(user_data)。
-
- 提交到我们的队列。
-
- 检查提交失败。
除了io_uring_prep_nop之外,还有大量的其他准备函数,例如从文件或TCP连接中进行阅读和写入。我建议快速搜索一下,看看有什么可用的。我们将在后面的文章中使用其中的一些。
我们要做的最后一件事是等待NOOP完成:
fn wait_for_completion(ring: &mut io_uring) -> io::Result<()> {
let mut cqe: *mut io_uring_cqe = null_mut();
let ret = unsafe { io_uring_wait_cqe(ring, &mut cqe) };
if ret < 0 {
return Err(io::Error::last_os_error());
}
unsafe {
println!("NOP completed with result: {}", (*cqe).res);
println!("User data: 0x{:x}", (*cqe).user_data);
io_uring_cqe_seen(ring, cqe);
}
Ok(())
}
这里我们创建一个指向io_uring_cqe的不可变指针,它是一个结构体,看起来像这样:
pub struct io_uring_cqe {
pub user_data: __u64,
pub res: __s32,
pub flags: __u32,
pub big_cqe: __IncompleteArrayField<__u64>,
}
这是一个完成队列条目,我们将把它传递给io_uring_wait_cqe,在完成之前的提交之后,它将把完成数据存储在指针指示的位置。
现在,我们只关心user_data字段,在检查io_uring_wait_cqe的返回值没有指示失败(即,它实际上是指向某个东西)。
这里发生的最后一件事是我们调用io_uring_cqe_seen来通知io_uring我们已经看到了这个完成。如果您查看文档,则必须完成此操作,否则完成队列的槽将无法重用。
最后,我们返回main,然后退出队列:
unsafe { io_uring_queue_exit(&mut ring) };
此功能将释放资源并将您从戒指管理的负担中解放出来。
结论
就这样,只需几千字加上一两页代码,就可以创建自己的liburing绑定和io_uring队列。这一点,至少从我在下一节所做的工作来看,是比较容易的部分。
我们离一个开源服务器还有很长的路要走,但我们正在接近!
转载自:https://juejin.cn/post/7403659734258270219