Redis事件循环与多线程IO

Redis 为什么快?

Redis 的高性能得益于以下几个基础:

  • C 语言实现,虽然 C 对 Redis 的性能有助力,但语言并不是最核心因素。
  • 纯内存 I/O,相较于其他基于磁盘的 DB,Redis 的纯内存操作有着天然的性能优势。
  • I/O 多路复用,基于 epoll/select/kqueue 等 I/O 多路复用技术,实现高吞吐的网络 I/O。
  • 单线程模型,单线程无法利用多核,但是从另一个层面来说则避免了多线程频繁上下文切换,以及同步机制如锁带来的开销。

Redis 为何选择单线程?

Redis 的核心网络模型选择用单线程来实现,这在一开始就引起了很多人的不解,Redis 官方的对于此的回答是:

It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

对于Redis 来说,大部分操作是纯内存的,CPU 通常不会是瓶颈,大多数请求不会是 CPU 密集型的,而是 I/O 密集型。Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟。

使用多线程(多进程)处理命令相比单线程提升并不会很大(不一定有提升),但开发量却高很多。多进程(多线程)调度过程中会有上下文切换开销,还有同步机制加锁解锁的开销

Redis真的是单线程?

当我们讨论 Redis 的多线程时,有必要对 Redis 的版本划出两个重要的节点:

  1. Redis v4.0-引入多线程处理异步任务
  2. Redis v6.0-正式在网络模型中实现 I/O 多线程

Redis单线程事件循环

从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),然后回写响应数据到客户端,所有事件(包括文件事件和时间事件)的处理都在单个线程内完成:

这是一个经典的reactor模式,Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。目前 Linux 平台上主流的高性能网络库/框架中,大都采用 Reactor 模式,比如 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。

对于那些想利用多核优势提升性能的用户来说,Redis 官方给出的解决方案也非常简单粗暴:在同一个机器上多跑几个 Redis 实例。

多线程和多进程异步任务

Redis 在 v4.0 引入多线程来做一些异步操作,主要针对的是那些耗时的命令和任务,通过将这些命令和任务的执行进行异步化,避免阻塞单线程的事件循环。
目前(v6.2)异步任务包括:

/* BIO- Background IO */
#define BIO_CLOSE_FILE 0 /* Deferred close(2) syscall.  aof文件重写后,异步关闭旧文件, */
#define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. 写完aof文件后异步调用一次fsync */
#define BIO_LAZY_FREE 2 /* Deferred objects freeing. 异步删除大key */

比如异步删除和非阻塞命令 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC。

UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中暂时移除掉,然后将任务添加到一个异步队列,最后由后台线程去删除,不过这里需要考虑一种情况是如果用 UNLINK 去删除一个很小的 key,用异步的方式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。

多进程目前主要用在BGSAVE、AOF重写

如何进一步提高Redis性能?

前面提到 Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。那么为什么现在 Redis 又要引入多线程呢?很简单,就是 Redis 的网络 I/O 瓶颈已经越来越明显了。利用多核优势(多线程)成为了优化网络 I/O 性价比最高的方案。

通常来说,单 Reactor 模式,引入多线程之后会进化为 Multi-Reactors 模式,基本工作模式如下:

这种模式不再是单线程的事件循环,而是有多个线程(Sub Reactors)各自维护一个独立的事件循环,由 Main Reactor 负责接收新连接并分发给 Sub Reactors 去独立处理,Sub Reactors 会完成 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor 只负责分派任务。

Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用这种多线程模型,虽然不同的项目实现细节略有区别,但总体来说模式是一致的。

Redis 多线程网络模型

Redis 虽然也实现了多线程,但是却不是标准的 Multi-Reactors模式,现在我们先看一下 Redis 多线程网络模型的总体设计:

这里大部分逻辑和之前的单线程模型是一致的,变动的地方仅仅是把读取客户端请求和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,I/O 线程仅仅是读取和解析第一条客户端命令而不会真正去执行命令,客户端命令还是在单线程的事件循环中执行的

当前多线程模型的问题

  1. Redis 的多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers 模型,最大的不同就是在 Multi-Reactors/Master-Workers 模式下,多个 Sub Reactors/Workers 会并发进行 网络读 -> 数据解析 -> 命令执行 -> 网络写 整套流程,Main Reactor/Master 只负责接受新连接和分派任务,而在 Redis 的多线程方案中,多个I/O 线程仅仅是并发地 完成网络读 和 网络写,却没有真正执行命令,所有客户端的命令最后还需要回到主线程去串行执行,因此对多核的利用率并不算高,而且每次主线程在分配任务之后需要忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。
    • Redis这么做的原因我觉得是因为 Redis之前是单线程的, 所有的数据结构都是非线程安全的,现在引入多线程,如果按照标准的 Multi-Reactors/Master-Workers 模式来实现,则所有内置的数据结构都必须重构成线程安全的,这个工作量无疑是巨大且麻烦的。
  2. 主线程和 I/O 线程的交互过于简单粗暴:忙轮询和锁,没有使用通知机制(频繁使用通知机制性能也有影响)。多线程IO开启后,主线程和子线程的所有等待都是通过自旋忙轮询实现,会导致CPU短暂空转引起高CPU使用率。
参考文档

https://strikefreedom.top/archives/multiple-threaded-network-model-in-redis

源码解析

redis原有的事件循环(epoll多路复用 + 非阻塞IO)

每次事件循环会

  1. 会先调用beforeSleep一次
  2. 然后设置epoll的最大等待时间为最早的时间事件的发生时间(每次需要遍历时间链表,但目前时间事件只有两个,所以还好),然后调用epoll或者select进行等待,epoll会在有文件事件发生或者超时后返回。
  3. epoll返回后调用afterSleep一次,afterSleep现在没有逻辑
  4. 开始处理epoll监听到的文件时事件。
    • 对于同一个套接字,先检查是否发生了读事件,如果发生调用读handler(readQueryfromClient)处理,然后检查是否发生了写事件并调用写handler(sendReplyToClient,需要在调用epoll前设置了写标志epoll才会监听写事件,命令处理完成会调用addReply将response先写到client->buf中,此时会设置写标志。当buf的数据全部发送完成后会清空写标志)。
    • 如果设置了invert标志,会先检查写事件并调用写handler,然后检查读事件并调用读handler(目前只有开启了AOF_FSYNC_ALWAYS 参数才有invert)
  5. 扫描时间事件链表,处理已经到时间的时间事件(每个时间事件有个时间戳表示应该处理的时间)

目前的时间事件只有两个,一个是serverCron,serverCron默认100ms执行一次,如果client数量过多,执行周期会变短,最多变到2ms一次。需要注意serverCron不是每次事件循环都执行。另一个是evictionTimeProc,当内存占用达到maxmemory时,performEvictions函数会清理过期key,performEvictions每次执行有个最大执行时间,如果超过最大执行时间后内存仍没有低于maxmemory,performEvictions会注册evictionTimeProc时间事件然后退出,这个时间事件会在每轮事件循环中调用performEvictions继续清理内存,当内存低于maxmemory后,会删除这个时间事件。

  • acceptCommonHandler会accept套接字,设置为非阻塞,生成redisClient结构,绑定 readQueryfromClient函数,让epoll监听读事件

  • readQueryfromClient函数正常情况下是先从套接字中read出最大16MB数据存到client->querybuf中,然后在while循环中解析querybuf(client->argc是参数个数,argv是sds类型的redisObject数组,会用argv[0]去redisCommandTable中查找命令)和执行命令(解析出一条立即执行,然后解析下一条),命令执行结束会调用addReply将结果写到client的输出缓冲中,然后为client设置写标志并绑定sendReplyToClient写handler,让epoll在下个事件循环中监听写事件。如果当前是 AOF_FSYNC_ALWAYS策略,会为这个client设置invert标志。

  • sendReplyToClient 函数在while循环中不断write发送缓冲的数据到套接字,先发送client->buf数组的数据,再发送client->reply链表的数据,如果client不能发送数据了,或者本次已经发送超过 REDIS_MAX_WRITE_PER_EVENT 64KB数据,退出。如果所有数据都已经发送完,移除写事件

redis6.0对事件循环的优化

Redis6.0之前,readQueryfromClient处理完用户命令后会调用addReply向redisClient发送缓冲添加响应,addReply会调用prepareClientToWrite,prepareClientToWrite会为这个套接字设置写标志并绑定 sendReplyToClient 写handler,这样发送缓冲的数据会在下个事件循环中发送给用户

Redis6.0以后,prepareClientToWrite只是将redisClient加入clients_pending_write队列,beforeSleep中将AOF缓冲数据落盘后,会为clients_pending_write队列中的每个redisClient发送数据(可能使用多线程IO)。如果beforeSleep中没有将redisClient发送缓冲中的数据发送完(发送的数据超过阈值或者不能发了),才会为这个套接字绑定 sendReplyToClient写handler并设置写标志,后续在事件循环中继续发送,等这个client发送缓冲的数据全部发送完成后,这个client才会在beforeSleep中发送数据。redis6.0发送数据主要是在beforeSleep中先尝试写,如果不能写才会绑定写事件,不用每次通过epoll监听写事件来触发, 降低了用户从发送命令到接收响应的延迟,减小CPU的消耗。

为什么AOF_FSYNC_ALWAYS 会导致invert?

因为正常情况下,事件循环对于同一个套接字(客户端)是先读后写的,读数据处理命令时产生的响应会写到client->buf中,然后设置写标志。由于读完数据才设置写标志,因此本次事件循环不会将响应发给client,而是要等到下次事件循环才会将响应发给client。但是到下次事件循环时,如果客户端再次发送了新的请求,也是先处理读事件,读事件处理完成,client->buf中就会有两条命令的响应,然后调用写handler将两条命令的响应发给client。由于写aof文件是在beforeSleep中, 这就导致在正常情况下,可能出现一条命令的请求处理完成,其结果在同一个事件循环中就立即发给client,如果机器在执行 beforeSleep前 宕机,会导致这条命令还没有写到aof文件中,出现命令丢失。通过设置invert,先写后读。所有的命令处理完成后,本次事件循环都不会将响应发给client,而是等到下个事件循环才发给client,这中间会经过beforeSleep,将命令写到aof中,确保在 AOF_FSYNC_ALWAYS 设置下命令永远不会丢失。

虽然边缘触发性能很高,但redis为什么用水平触发而不是边缘触发?

  • 水平触发
    • 对于读操作 :只要缓冲内容不为空,LT模式返回读就绪。
    • 对于写操作 :只要缓冲区还不满,LT模式会返回写就绪。
  • 边缘触发
    • 对于读操作
      1. 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
      2. 当有新数据到达时,即缓冲区中的待读数据变多的时候。
      3. 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
    • 对于写操作
      1. 当缓冲区由不可写变为可写时。
      2. 当有旧数据被发送走,即缓冲区中的内容变少的时候。
      3. 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。

多线程IO

redis有个 io_threads_active 参数动态控制多线程IO是否开启,这个参数初始值是0。另外还有个 io_threads_do_reads 参数控制是否开启多线程IO读,这个参数在程序中不能动态修改,因为作者认为多线程读带来的收益不大,可以通过这个参数关闭多线程读,只开启多线程写

多线程IO开启的条件

多线程IO最开始没有开启,事件循环像正常那样先调用读handler解析和处理命令,addReply在将第一条命令的执行结果写到client的输出缓冲时,会将client加入到 clients_pending_write 队列中(不像以前那样会为client设置写标志并绑定写handler),然后在下个事件循环的beforSleep中调用handleClientsWithPendingWritesUsingThreads,里面会调用 stopThreadedIOIfNeeded 看是否要关闭多线程IO,关闭的条件是clients_pending_write的size小于server.io_threads_num*2 如果没有关闭就会开启多线程IO(开启多线程IO后主线程会释放每个线程的锁,导致每个IO线程一直在死循环中检查自己的IOPendingCount(任务的数量)是否大于0,在多线程IO开启期间存在严重的性能消耗)。

void beforeSleep(struct aeEventLoop *eventLoop) {
    ......
    /* We should handle pending reads clients ASAP after event loop. */
    handleClientsWithPendingReadsUsingThreads();

    /* Write the AOF buffer on disk */
    // 先写aof,再执行 handleClientsWithPendingWritesUsingThreads
    if (server.aof_state == AOF_ON)
        flushAppendOnlyFile(0);

    /* Handle writes with pending output buffers. */
    handleClientsWithPendingWritesUsingThreads();
    ......
}

int handleClientsWithPendingWritesUsingThreads(void) {
    ......
    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {
        // 如果需要关闭多线程IO,调用handleClientsWithPendingWrites处理
        return handleClientsWithPendingWrites();
    }
    // 如果stopThreadedIOIfNeeded返回false,且此时多线程IO未开启,则会开启多线程IO
    if (!server.io_threads_active) startThreadedIO();
    ......
}

多线程IO开启后的处理流程

多线程IO刚开启的时候,此时 clients_pending_write 队列中client的输出缓冲中的数据还没有发送出去,主线程此时会为每个IO线程分配写任务,将clients_pending_write中的client均匀地添加到每个IO线程的任务队列中,设置每个IO线程的IOPendingCount为任务队列的size,IO线程检测到IOPendingCount不为0后,为任务队列的每个client调用writeToClient(等效于sendReplyToClient)函数:{ 发送client缓冲中的数据,先发送client->buf数组的数据,再发送client->reply链表的数据,如果client不能发送数据了(redis为了提升性能,发送数据是先直接写,等到不能写了再监听写事件,而不是像以前,发送数据前需要先监听写事件),或者本次已经发送超过64KB数据,退出,然后为这个客户端设置写事件并绑定sendReplyToClient,如果当前是 AOF_FSYNC_ALWAYS策略,会为这个client设置invert标志。如果client发送缓冲所有数据都已经发送完,如果有写事件则移除},IO线程为所有client调用完writeToClient函数后,清空自己的任务队列,设置IOPendingCount 为0,然后继续在死循环中检测IOPendingCount。主线程分配任务后,会为0号任务队列中的所有client调用writeToClient(主线程也有任务队列),然后自旋死循环等待所有子线程完成任务后,清空clients_pending_write队列后结束。

多线程IO开启后,epoll监听到读事件第一次调用readQueryfromClient时,readQueryfromClient会将client加入 clients_pending_read队列(只有开启多线程IO client才会进入这个队列),然后立即返回。在beforeSleep中会调用handleClientsWithPendingReadsUsingThreads,主线程此时会为每个IO线程分配读任务,将clients_pending_read中的client均匀地添加到每个IO线程的任务队列中,设置每个IO线程的IOPendingCount为任务队列的size,IO线程检测到IOPendingCount不为0后,为任务队列的每个client调用readQueryFromClient函数:{ 第二次调用 readQueryfromClient函数时,从套接字中read出最大16MB数据存到client->querybuf中,解析出第一条命令,立即返回 }。IO线程为所有client调用完readQueryFromClient函数后,清空自己的任务队列,设置IOPendingCount 为0,然后继续在死循环中检测IOPendingCount。主线程分配任务后,自己会为0号任务队列中的所有client调用readQueryFromClient,然后自旋死循环等待所有子线程完成任务,清空clients_pending_read队列,在while循环中依次为每个client执行解析好的第一条命令,然后解析和执行后续的命令,addReply在将命令的执行结果写到client的输出缓冲时,会将client加入到 clients_pending_write 队列中(如果client已经有写标志则不会加入队列),最后结束。

多线程IO开启后,beforeSleep中会调用handleClientsWithPendingWritesUsingThreads,这个函数除了完成多线程IO的动态开启和关闭,还会取出clients_pending_write队列中的client,完成数据的并发发送,发送流程和上面一致。

多线程IO关闭的条件

多线程IO开启后, beforeSleep中会调用handleClientsWithPendingWritesUsingThreads,里面会调用 stopThreadedIOIfNeeded,以及serverCron中也会调用stopThreadedIOIfNeeded(避免客户端没有请求时多线程IO一直自旋浪费CPU),看是否要关闭多线程IO,如果需要关闭,主线程会对每个IO线程的锁加锁,IO线程会进入锁等待,让出CPU。当多线程IO关闭后,主线程会为clients_pending_write 队列(不管多线程IO是否开启,client要发送数据都会添加到clients_pending_write队列,这个队列用来动态开启关闭多线程IO)中每个client串行调用writeToClient,然后清空 clients_pending_write 队列。

IO 线程的执行逻辑

void *IOThreadMain(void *myid) {
    ...
    // 初始化时设置IO线程的CPU亲和性,目的是尽可能将IO线程和主线程绑定到不同的CPU核上,避免相互竞争CPU。
    redisSetCpuAffinity(server.server_cpulist);
    ...

    while(1) {
        /* Wait for start */
        // 自旋等待 IOPendingCount != 0, 严重消耗CPU
        // 主线程分配任务后会把 IOPendingCount 设为任务队列的size,让IO线程退出自旋
        for (int j = 0; j < 1000000; j++) {
            if (getIOPendingCount(id) != 0) break;
        }

        /* Give the main thread a chance to stop this thread. */
        // 等了一百万次还没有任务,加锁再放锁,然后继续等一百万次
        if (getIOPendingCount(id) == 0) {
            // 如果主线程需要关闭多线程IO(stopThreadedIO) ,主线程会加锁,导致当前线程锁等待
            // 开启多线程IO时主线程会放锁,导致IO线程加锁不会阻塞,IO线程只能自旋忙轮询等待。
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(getIOPendingCount(id) != 0);

        /* Process: note that the main thread will never touch our list
         * before we drop the pending count to 0. */
        // 注意:主线程分配任务给 I/O 线程之时,
        // 会把任务加入每个线程的本地任务队列 io_threads_list[id],
        // 但是当 I/O 线程开始执行任务之后,主线程就不会再去访问这些任务队列,避免数据竞争。
        listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                // 如果当前是写出操作,则把 client 的写出缓冲区中的数据回写到客户端。
                writeToClient(c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                // 如果当前是读取操作,则read客户端的请求并解析第一条命令。
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        // 把任务做完再将IOPendingCount设为0,主线程通过检测IO线程的IOPendingCount
        // 是否为0来退出自旋等待
        setIOPendingCount(id, 0);
    }
}

为什么要用多线程IO加速数据的读写

因为网络的read、write操作是系统调用,系统调用由于涉及内核态和用户态之间的切换会比较慢,使用多线程并发调用read、write,相比单线程串行调用,会快些。

Redis的异步任务

目前共有三种后台异步任务

  1. 对文件执行fsync
    1. beforeSleep调用 flushAppendOnlyFile,将aof缓冲区write到aof文件后,如果aof策略为AOF_FSYNC_EVERYSEC,会提交异步fsync任务
    2. 主进程发现aof重写子进程完成后调用backgroundRewriteDoneHandler,将aof重写缓冲区的数据write到新aof文件后,如果aof策略为AOF_FSYNC_EVERYSEC,会提交异步fsync任务
  2. 对文件执行close
    主进程发现aof重写子进程完成后,在backgroundRewriteDoneHandler中将新生成的临时文件改名为aof文件,然后异步close旧aof文件
  3. 异步删除
    • UNLINK 命令其实就是 DEL 的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中暂时移除掉,然后将删除任务添加到一个异步队列,最后由后台线程去删除。不过这里需要考虑一种情况是如果用 UNLINK 去删除一个很小的 key,用异步的方式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。
    • FLUSHDB 和 FLUSHALL 指令用来清空整个数据库,如果在指令后面增加 async 参数会采用异步删除
    • 后台的异步删除进程会将这个key指向的所有内存空间全部释放掉,因此Redis的不同key(对象)之间不能在底层有任何共享内存。

每种后台任务都有一个任务线程、一个任务队列、一把锁和一个条件变量(用于实现通知机制)。
任务线程从任务队列中取出任务来执行,如果没有任务则在条件变量上wait,等待主线程提交任务时的通知

// 主线程提交任务,需要加锁和放锁,其实也比较影响主线程性能
void bioSubmitJob(int type, struct bio_job *job) {
    // 修改共享变量之前需要加锁
    pthread_mutex_lock(&bio_mutex[type]);
    // 把任务添加到任务队列尾部
    listAddNodeTail(bio_jobs[type],job);
    // 增加任务size
    bio_pending[type]++;
    // 通知机制:通知 bioProcessBackgroundJobs 准备处理任务
    pthread_cond_signal(&bio_newjob_cond[type]);
    // 修改完共享变量后放锁
    pthread_mutex_unlock(&bio_mutex[type]);
}

// 每个任务线程启动时执行这个函数
void *bioProcessBackgroundJobs(void *arg) {
    // 绑定当前任务线程到指定的CPU
    redisSetCpuAffinity(server.bio_cpulist);
    // 先加锁
    pthread_mutex_lock(&bio_mutex[type]);

    while(1) {
        /* The loop always starts with the lock hold. */
        // 执行到这里时当前线程一定是获取到锁的
        if (listLength(bio_jobs[type]) == 0) {
            // 如果没有任务,在条件变量上等待 bioSubmitJob 提交任务的通知
            pthread_cond_wait(&bio_newjob_cond[type],&bio_mutex[type]);
            continue;
        }
        // 从共享变量中读出任务,必须等执行完后才能删除
        listNode ln = listFirst(bio_jobs[type]);
        job = ln->value;
        /* It is now possible to unlock the background system as we know have
         * a stand alone job structure to process.*/
        // 读出任务后释放锁
        pthread_mutex_unlock(&bio_mutex[type]);
         
         // 执行任务
        /* Process the job accordingly to its type. */
        ...


        /* Lock again before reiterating the loop, if there are no longer
         * jobs to process we'll block again in pthread_cond_wait(). */
        // 任务执行完后,从共享变量中删除任务,然后继续读取下一个任务,需要再次加锁
        pthread_mutex_lock(&bio_mutex[type]);
        // 获得锁后修改共享变量,删除已经成功执行的任务
        listDelNode(bio_jobs[type],ln);
        bio_pending[type]--;

    }
}
posted @ 2022-08-01 14:43  zoo-keeper  阅读(447)  评论(0)    收藏  举报