搞Linux开发的谁还没跟信号打过几次交道。不管你是写服务端程序要处理优雅退出还是排查线上进程突然消失的诡异问题最终都会查到signal这组函数头上。标题里这几个字看着简单真用起来坑不少光signal()和sigaction()那点区别就够新手喝一壶的。这篇文章不打算按man手册从头念到尾我直接结合自己实际写过的网络服务、多进程守护程序把信号相关函数怎么选、怎么用、踩过哪些坑一次说清楚。适合刚接触Linux系统编程的读者也适合写了几年代码但没系统性捋过信号机制的朋友。1. 信号机制与函数全景1.1 信号到底是什么信号本质上是一种软件中断。它没有优先级没有参数传递除了一两个固定信息就是告诉进程“某件事发生了”。这个“某件事”可以来自内核比如你写了非法的内存地址内核发SIGSEGV也可以来自别的进程比如你在终端按CtrlC内核给前台进程组发SIGINT还可以来自进程自己比如调用abort()主动触发SIGABRT。我经常用一个类比来解释信号进程就像正在做饭的厨师信号就是旁边有人喊“锅糊了”。厨师听到后不管当前手里在切菜还是炒菜都得先停下来去处理糊锅这件事处理完再决定是继续切菜还是重新炒。在Linux里这个“停下来去处理”的动作就是中断当前执行流跳转到信号处理函数如果注册了的话处理完再恢复之前的执行流。信号机制的关键点在于它是异步的。你永远不知道信号什么时候来代码执行到哪一行的时候来。这就导致了一个核心问题信号处理函数里能做的事情极其受限很多常规操作在里面做都是不安全的。1.2 相关函数分类图谱Linux信号相关函数可以分成四大类我画不出流程图直接用表格说事类别核心函数主要职责信号发送kill()、raise()、alarm()、pause()、sigqueue()给进程或进程组发信号或让进程等待信号信号处理signal()、sigaction()注册信号处理函数改变信号默认行为信号集操作sigemptyset()、sigfillset()、sigaddset()、sigdelset()、sigismember()操作信号集为阻塞和等待做准备信号阻塞与等待sigprocmask()、sigpending()、sigsuspend()屏蔽一组信号、查询未决信号、原子性等待信号实际项目里用得最多的组合是sigaction()注册处理函数、sigprocmask()在临界区屏蔽信号、sigsuspend()等待信号到来。这套组合拳能解决绝大多数并发场景下的信号问题。另一个要提前说清楚的概念是信号的默认行为。每个信号都有一个默认行为比如SIGINT默认终止进程SIGCHLD默认忽略SIGSEGV默认产生core dump。如果你不注册任何处理函数信号到来时就按默认行为走。注册处理函数本质上就是覆盖这个默认行为。1.3 信号的一生从产生到处理我习惯把一个信号从产生到被处理分成三个阶段信号产生、信号阻塞/未决、信号递达。信号被发送后如果进程没有阻塞该信号那么内核会尽快让它“递达”也就是触发默认动作或调用处理函数。如果进程阻塞了该信号那么信号就停留在“未决”状态排队等待直到进程取消阻塞才递达。这里有个容易忽略的细节标准信号1~31号不排队。意思是如果在未决状态下重复来了多次同样的信号内核只会记录一次后面来的会被丢弃。实时信号32号以后才排队。很多新手写守护进程时问“为什么我发了10次信号只处理了1次”答案就在这里。信号处理函数执行完毕后进程会从被中断的系统调用处继续往下走。但是很多系统调用在被信号打断后会返回错误码EINTR。这个点我会在后面“常见问题”那一章重点展开因为它是导致线上程序表现诡异的高频原因。2. 信号处理函数深度解析2.1 signal与sigaction怎么选先说结论一律用sigaction()别用signal()。signal()的问题在于它的行为在不同UNIX系统间不一致。在老版本的System V上信号处理函数执行完后该信号的处置方式会被重置为默认行为。这意味着如果你在处理函数里没重新调用signal()去注册自己下一次同一个信号到来时就直接按默认行为把进程杀了。而在BSD系统上signal()又不会重置行为像sigaction()。Linux上的signal()使用的是BSD语义但glibc对它的封装在不同编译选项下表现也不一样。这不确定性是生产环境的大忌。signal()还有一个硬伤它无法屏蔽信号处理函数执行期间同信号再次到达。假设你正在处理SIGINT此时又来了一个SIGINT处理函数会被再次打断重入。这在线程里、在关键数据结构操作过程中都是隐患。sigaction()可以通过sigaction结构体里的sa_mask字段在处理函数执行期间自动阻塞一组信号。这就干净得多。看一个对比案例。某同学写了一个简单服务用signal(SIGINT, handler)处理CtrlC做清理工作。结果有一次CtrlC按得稍微快了点第二次信号来了直接触发默认行为终止进程清理只做了一半。换成sigaction()配合sa_mask后处理函数执行期间的SIGINT信号被挂起先处理完当前这次再处理后续的问题就没了。2.2 sigaction完整示例与参数解读下面给一个生产环境可用的信号处理注册函数注释里写清楚每个参数#include stdio.h #include string.h #include signal.h #include unistd.h static volatile sig_atomic_t g_flag 0; static void handle_int(int sig, siginfo_t *info, void *context) { // 在信号处理函数里尽量做简单操作 // 这里只置一个标志位主循环去检查 (void)sig; (void)info; (void)context; g_flag 1; } int setup_signal_handler(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); // SA_RESTART: 被信号打断的系统调用自动重启动 // SA_SIGINFO: 使用三参数形式的处理函数能拿到更多信号信息 sa.sa_flags SA_RESTART | SA_SIGINFO; sa.sa_sigaction handle_int; // 在处理函数执行期间屏蔽以下信号 sigemptyset(sa.sa_mask); sigaddset(sa.sa_mask, SIGINT); sigaddset(sa.sa_mask, SIGTERM); if (sigaction(SIGINT, sa, NULL) 0) { perror(sigaction(SIGINT)); return -1; } return 0; }sa_mask的作用范围仅在信号处理函数执行期间。它不是永久阻塞处理函数一返回这些信号就恢复原状。sig_atomic_t类型要专门说明一下。它是C标准提供的一个原子整数类型在信号处理函数和普通代码之间共享变量时应该用它。现代处理器上普通int读写本身是原子的但C标准为了保证跨平台安全性还是建议用sig_atomic_t。配合volatile关键字防止编译器把对该变量的访问优化到寄存器里保证每次读取都真正从内存拿。2.3 信号处理函数内的安全操作清单信号处理函数的执行是异步的这意味着它可能在主程序的任何一条指令之间插入执行。如果处理函数里调用了不安全函数可能和主程序的数据结构操作互相践踏导致不可预期的后果。我在项目中严格执行一个标准信号处理函数里只做三件事。第一置标志位。用volatile sig_atomic_t记录信号是否到来、信号编号是什么。第二向管道写入一个字节的自管道通知。这个技巧是为了配合select/poll/epoll事件循环使用的。信号处理函数往一个管道写端写1个字节主循环监听这个管道读端一旦可读就知道有信号发生了。这比在信号处理函数里做复杂处理靠谱得多也解决了主循环阻塞在epoll_wait()时无法及时响应信号的问题。这技术叫self-pipe trickLeetCode式命名实战很常用。第三对于需要快速响应多类信号的场景可以在处理函数里直接调用_exit()做紧急退出跳过一切清理。这虽然粗暴但有时候是从异常状态中恢复的唯一手段。绝对不要在信号处理函数里做的事情也列一下调用printf()内部锁和缓冲区的状态可能处于不一致状态如果你在别处也调printf会产生死锁、malloc()/free()堆管理器的内部链表状态不可信、fopen()等文件IO操作、pthread_mutex_lock()如果主程序持有锁这里直接死锁。2.4 一个让printf在信号处理函数中变安全的方法我知道肯定有人问那我日志系统的信号处理函数就是要输出信息怎么办一条路是用write()系统调用配合vsnprintf()。write()本身是异步信号安全的vsnprintf()操作的是自己的缓冲区也不会触碰共享状态。先格式化到栈上的字符数组里然后调用write()把字符串写向fd。只要fd对应的文件是O_APPEND模式多个写操作追加的时候不会互相覆盖。我写过一个简单的信号日志封装大概就是这个思路static void log_in_signal_handler(int saved_errno, const char *fmt, ...) { char buf[256]; char msg[320]; int len; va_list ap; len snprintf(buf, sizeof(buf), [sig] errno%d , saved_errno); va_start(ap, fmt); vsnprintf(buf len, sizeof(buf) - len, fmt, ap); va_end(ap); msg[0] \0; strncat(msg, buf, sizeof(msg) - 1); int fd open(/tmp/signal.log, O_WRONLY | O_CREAT | O_APPEND, 0644); if (fd 0) { write(fd, msg, strlen(msg)); write(fd, \n, 1); close(fd); } }不过说实话每次信号处理都open一次文件效率很低而且open本身是否安全在不同系统上还有争议。更稳妥的做法是日志输出只置标志位把真正的日志工作放到主循环里做。这样也符合“信号处理函数保持极简”的原则。3. 信号发送与进程间协同3.1 kill、raise、alarm、pause的使用场景kill()函数名字很吓人但它其实只是“给进程发送信号”的通用接口。名字来源于早期UNIX系统里默认动作就是杀死进程。给同一个进程组的所有进程发信号传pid0给调用者所属进程组的所有进程发信号传pid-1。权限校验是发送方和接收方的real uid或saved uid必须匹配或者调用者必须有root权限。raise()是给调用进程自己发信号本质上是kill(getpid(), sig)。它的使用场景是进程主动触发某个信号来走一遍处理流程比如主动触发SIGUSR1去重读配置文件。alarm()是定时器设置一个闹钟多少秒后内核给当前进程发SIGALRM。因为SIGALRM的默认行为是终止进程所以使用alarm()之前必须先用sigaction()注册SIGALRM的处理函数。每次调用alarm()会取消之前的闹钟返回之前闹钟剩余的秒数。基于这个特性可以实现超时机制unsigned int remaining alarm(0); // 取消现有闹钟 if (remaining 0) { // 之前还有未到期的闹钟重新设置 alarm(remaining); } else { alarm(5); // 设置新的5秒超时 }这个取消再重置的过程要注意原子性不然在取消和重置之间闹钟到了SIGALRM就会以默认行为杀掉进程。严谨的写法是先用sigprocmask阻塞SIGALRM再操作闹钟最后解除阻塞。我这里给出一个完整示例。pause()的作用是挂起调用进程直到收到一个不会被忽略的信号。它的返回值只有-1并设置errnoEINTR。pause()使用场景有限更多的是用sigsuspend()替代后者可以原子地恢复信号阻塞并等待避免竞态条件。3.2 多进程策略SIGCHLD和僵尸进程父进程不关心子进程退出状态时用signal(SIGCHLD, SIG_IGN)可以告诉内核自动回收子进程资源避免僵尸进程。这个方法简便但代价是再也拿不到子进程的退出码。如果需要知道子进程的退出状态就得在SIGCHLD处理函数里调用waitpid()static void handle_sigchld(int sig) { int saved_errno errno; int status; pid_t pid; // WNOHANG: 不阻塞有子进程退出就回收没退出立即返回 while ((pid waitpid(-1, status, WNOHANG)) 0) { // 在这里记录 pid 和 status } errno saved_errno; }saved_errno的保存和恢复是我强烈建议的一个好习惯。因为信号处理函数可能发生在任何系统调用之后那些系统调用失败时会设置errno。如果处理函数里调用的函数修改了errno回到主程序后错误的errno信息会污染后续的错误判断。while循环里用waitpid(-1, WNOHANG)是为了把多个同时退出的子进程全部回收。如果只调用一次waitpid()可能在多个子进程同时退出时只能回收一个留下僵尸。循环回收直到返回0或-1就干净了。3.3 sigqueue与实时信号的值传递sigqueue()比kill()多传递一个附加数据int或指针。配合sigaction()的SA_SIGINFO标记接收方的处理函数三参数版本里siginfo_t结构的si_value字段就能拿到发送方传来的数据。// 发送方 union sigval value; value.sival_int 100; sigqueue(target_pid, SIGUSR1, value); // 接收方在handle中 int received info-si_value.sival_int;实时信号SIGRTMIN到SIGRTMAX的一大优势是排队。标准信号如果处于未决状态重复发送只会合并成一次实时信号每次发送都会排队处理函数会依次执行不丢信号。我这里说一个实际场景某采集程序用实时信号和多个工作进程协作每个工作进程要报告自己的编号就用sigqueue(a_worker_pid, SIGRTMIN n, value)让每个工作进程的信号处理函数里都能从si_value里取得自己的编号。这样用一个信号通道就完成了工作进程的“按号入座”不需要额外的共享内存通信。这类用法在低延迟的场景里比管道通信要少一次唤醒效果立竿见影。4. 信号阻塞与原子性等待4.1 sigprocmask屏蔽信号的正确姿势sigprocmask()允许进程暂时屏蔽一组信号使它们进入未决状态不被递达。使用场景除了刚才说的闹钟时序问题还有一类经典需求临界区保护。假设主流程维护一个队列数据处理到一半的时候来了一个SIGTERM处理函数里立刻执行清理逻辑而队列此时处于中间状态清理就会翻车。解决思路是在进入临界区之前屏蔽信号处理完队列之后再恢复信号。sigset_t newmask, oldmask, zeromask; sigemptyset(newmask); sigaddset(newmask, SIGTERM); // 屏蔽SIGTERM sigprocmask(SIG_BLOCK, newmask, oldmask); // 临界区操作共享数据这里绝不能收到SIGTERM manipulate_shared_queue(); // 恢复原来的屏蔽状态 sigprocmask(SIG_SETMASK, oldmask, NULL);注意oldmask的保存和恢复。如果进程在别处已经屏蔽了SIGTERM我这里直接SIG_SETMASK恢复成空集就把之前的状态破坏了。先保存再恢复是防御性编程的基本素养。还容易忽略的一点是sigprocmask()在多线程程序里不是线程级操作是整个进程级的。多线程程序应该用pthread_sigmask()来控制每个线程自己的信号屏蔽状态。这个话题再展开就是另一个大坑信号在进程里究竟投递到哪个线程答案是投递给任意一个没有屏蔽该信号的线程。所以多线程程序里信号处理常常配合“专门线程统一接收信号”模式。4.2 sigpending查看未决信号sigpending()用来查看当前进程有哪些信号处于未决状态已发送但被阻塞未递达。调试信号问题时非常有用。比如怀疑某个信号丢了可以先阻塞它再发一个然后调用sigpending()确认它是否已到达。如果pending里有这个信号说明信号没丢只是还没处理。sigset_t pending; sigpending(pending); if (sigismember(pending, SIGTERM)) { printf(SIGTERM is pending\n); }注意sigpending()只反映当前线程的未决信号集合。对单线程进程来说一目了然多线程就要注意了。4.3 sigsuspend原子等待与竞态破解sigsuspend()是Linux信号编程里最容易出错也最需要理解的一个函数。它的行为分两步把进程的信号屏蔽字设置为参数指定的值然后挂起进程等待信号。关键在“原子性”——这两步是连续的中间不会有信号穿进来。为什么需要原子性考虑这个场景你在屏蔽了SIGINT的情况下做一些初始化完成后解除屏蔽并等待SIGINT。如果写成先sigprocmask(SIG_SETMASK, oldmask, NULL)再pause()在恢复屏蔽和pause()之间的空隙里SIGINT可能已经到达并被处理了。等pause()挂起时这个信号已经过去了进程会一直睡下去。sigsuspend()直接把旧mask传给挂起操作恢复和等待合成一步信号没法从中间溜进来。sigset_t newmask; sigemptyset(newmask); sigaddset(newmask, SIGINT); // 先屏蔽SIGINT sigprocmask(SIG_BLOCK, newmask, oldmask); // 执行需要排除SIGINT干扰的初始化 do_initialization(); // 原子地将屏蔽字恢复为oldmask并等待信号 sigsuspend(oldmask);这里sigsuspend()返回后进程的屏蔽字又会恢复为sigsuspend()调用前的状态也就是newmask那个屏蔽SIGINT的状态。所以如果期望之后继续屏蔽SIGINT这里无需再调用sigprocmask如果期望彻底恢复不屏蔽那么还需要手动调用sigprocmask(SIG_SETMASK, oldmask, NULL)。4.4 守护进程里的信号等待主循环在写守护进程的时候主循环的休眠和信号处理必须用sigsuspend()而不是sleep()或者空转。sleep()被信号中断后剩余秒数需要重新计算空转则可以让CPU占用直接拉满。一个标准模式是主循环阻塞所有需要处理的信号然后sigsuspend()挂起等信号。信号处理函数置标志位后返回sigsuspend()被打断主循环检查标志位并执行对应动作。static volatile sig_atomic_t g_need_reload 0; static void handle_reload(int sig) { g_need_reload 1; } int main(void) { sigset_t blockmask, emptymask; struct sigaction sa; sigemptySet(sa.sa_mask); sa.sa_flags 0; sa.sa_handler handle_reload; sigaction(SIGUSR1, sa, NULL); sigemptyset(blockmask); sigaddset(blockmask, SIGUSR1); sigprocmask(SIG_BLOCK, blockmask, NULL); sigemptyset(emptymask); while (1) { sigsuspend(emptymask); if (g_need_reload) { reload_config(); g_need_reload 0; } } }这段代码的巧妙之处在于主循环几乎不消耗CPU信号来了立刻响应而且不会在不可预期的位置打断临界区操作。整个“检查标志位”和“执行操作”都在主线程自己的流程里不涉及异步上下文安全性大幅提升。5. 常见问题与排查技巧实录5.1 EINTR被信号打断的系统调用这是Linux信号编程里最常见、最坑的一个现象。当进程阻塞在一个系统调用上比如read()等管道数据、accept()等连接、epoll_wait()等事件信号到来且处理函数返回后这些系统调用不会自动恢复继续等而是返回-1并设置errnoEINTR。如果不处理代码会误以为真的出错直接退出或继续跑错逻辑。处理方式无非两种。第一种是给sigaction的sa_flags加SA_RESTART这样大多数系统调用被信号打断后内核会自动重启不需要用户代码操心。但epoll_wait()、poll()、select()、nanosleep()等少数系统调用即使设置了SA_RESTART也不会自动重启。第二种是自己对EINTR做循环重试do { ret epoll_wait(epfd, events, maxevents, timeout); } while (ret 0 errno EINTR);网络服务里我一般是能加SA_RESTART的加不能自动重启的系统调用全部包一层EINTR重试循环。实测下来的经验是很多“程序时不时CPU占用突变”的问题都跟EINTR处理不当有关。某个网络模块在锁竞争激烈的条件下频繁收到信号epoll_wait反复返回EINTR外层如果恰好把返回值用作SN序号或缓存池索引瞬间就乱了。所以EINTR不只是一个errno它可能导致更复杂的逻辑错误。5.2 信号处理函数里的不可重入函数信号处理函数的执行跟主程序完全是乱序交错的。主程序可能在printf内部缓冲区更新到一半信号处理函数里调printf就可能导致数据混乱甚至死锁。主程序可能在malloc内部链表操作未完成时被打断处理函数里调malloc会让堆结构彻底损坏。我见过一个真实案例某个报表进程偶尔在午间崩溃。排查下来发现午间定时任务会发送SIGALRM而信号处理函数里有一个fprintf()写日志操作。主程序同时在另一个线程里用stdout写报表数据两份输出争抢同一个FILE结构体的锁第二次锁还没等到就直接推倒了。把信号处理函数的日志改成自管道通知后这个问题再也没出现过。排查这类问题有一个技巧用strace -p挂住进程观察信号到达时进程正在做什么系统调用。如果信号处理函数里频繁调用系统调用而主线程又在等待同一个资源多半就是重入冲突了。5.3 僵尸进程堆积SIGCHLD处理不彻底刚才提过SIGCHLD处理函数里要用waitpid(-1, WNOHANG)循环回收。有些开发者写了SIGCHLD处理函数却只调用一次waitpid()子进程多的时候就会留下僵尸进程。僵尸进程不占用CPU也不占用内存只占一个进程表项但它不释放pidpid耗尽后fork()就失败了。排查方法很简单查看进程表里ps -ef | grep defunct或者查看/proc/PID/task/PID/children看有没有多个子进程卡在退出状态。处理方式是修改SIGCHLD处理函数用循环把能回收的子进程全部回收或者用sigaction(SIGCHLD, sa, NULL)配合SA_NOCLDWAIT标志让内核在子进程退出时直接移除进程表项完全不等父进程调用waitpid()。SA_NOCLDWAIT适合完全不关心子进程退出状态的程序。但如果你需要统计子进程成功失败数量来报警告就不得不自己waitpid()收集status。5.4 信号丢失之谜标准信号不排队再强调一次这个最反直觉的细节普通信号发送100次如果接收方阻塞着没处理内核只会保留一个pending标记。后面99次全扔了。这是标准信号的设计为了节省内核资源。实时信号不一样每个实时信号都能排队。实际工作中很多传输重要通知的业务沟通用实时信号SIGRTMIN n就是为了不丢消息。比如某个项目状态机里有“配置备份完成”这个事件从A进程发给B进程A在一个循环里发了几次B如果只收到一次状态机就跳过去了。后来全部改成实时信号配合sigqueue()传状态值丢失问题一次性解决。5.5 问题排查速查表现象可能原因排查手段解决方向程序收到信号后直接退出不执行清理信号未注册处理函数或注册的是SIG_DFL检查sigaction()返回值和信号编号是否正确注册自定义处理函数检查信号编号与sigaction参数信号处理函数里调printf死锁主程序同时持有stdout锁或FILE结构体冲突gdb attach看线程栈会发现两个线程都在_IO_fwrite或_IO_file_lock相关函数里卡住处理函数里只置标志位或write()日志放主流程系统调用频繁返回EINTR缺少SA_RESTART或用不能用该标志的系统调用strace -p看到EINTR返回码重试循环包裹或者用epoll的EINTR重试封装waitpid只回收了一个子进程僵尸堆积SIGCHLD处理函数没有循环回收ps -ef | grep defunct看数量/proc下看children改成while(waitpid(-1, st, WNOHANG) 0)循环信号多次发送只触发一次处理标准信号不排队期间处于阻塞/pending状态只记录一次在接收方用sigpending()检查pending状态换用实时信号sigqueue()把关键信息放si_value里5.6 排查异常问题的三板斧信号问题排查起来不像段错误那么直接。你很难用一个map图框架去追踪异步行为的执行流程。我自己的排查路线基本固定为先确认注册、再确认发送、最后确认行为。第一板斧是确认注册有没有成功。写一个小测试程序sigaction()注册处理函数手动raise()对应的信号看处理函数有没有触发。没触发就查信号编号、sigaction结构体初始化和权限设置。第二板斧是确认信号有没有到达。在发送方kill()后立即看返回值和errno。kill()返回0不代表处理函数一定执行了只代表信号已经挂到了目标进程的未决队列里。如果目标进程阻塞了该信号处理函数不会立刻跑这时候用sigpending()去看pending集合最直观。第三板斧是确认处理函数本身没问题。在入口第一行直接_exit()如果在信号里最后真的_exit了说明处理函数里别的东西导致卡死或异常。然后从第一行开始逐步往回加代码二分定位到底哪个操作不安全。我处理过好几个诡异的线上问题最后都是靠这个“二分法排除法”锁定了罪魁祸首。6. 最后补充几个实际项目心得写到最后分享几个我长期实测下来的个人经验。信号编程本质上就是一个“异步并发”问题凡是涉及共享数据的操作在信号处理函数里能不做就不做这应该刻在潜意识里。如果实在要做也要严格控制数据长度用栈上缓冲区别碰malloc或锁。把日志挪出信号处理函数、把实际业务逻辑延后到主循环处理是让我跑生产环境最安稳的一种模式。自管道通知配合事件循环需要处理的事件都能在正常上下文里处理所有错误码、状态管理、锁的获取都在预期之内。还有一点用strace -f -e tracesignal观测进程收到的所有信号可以看到信号序列、处理函数是否调用甚至能看出信号处理函数里再产生信号的路径。处理完实时信号之后建议调用signal(sig, SIG_DFL)吗不要除非你确定后续不需要再捕获信号了。统一设置好sa_mask让信号处理期间的信号挂起到背后排队不用处理函数里手动恢复信号。最后说一下编译选项。Linux上使用signal.h和sigaction不需要特殊宏定义只要编译器不把“符合POSIX标准的特性”限制得太死就可以。如果你写多线程程序记得把_POSIX_C_SOURCE定义在源码最前面不然一些扩展的宏和类型可能不可见。这套内容你系统都过一遍之后再回去看那些kill -9杀不掉的进程、莫名其妙的EINTR、僵尸子进程堆积都会有新的理解。信号就是这样一个虽小但很重要的系统编程核心理解了异步边界你写的程序才敢真正放到长时间不重启的生产环境里去跑。