第一章:Go语言协程与信号处理的底层契约
Go 运行时在操作系统线程(OS thread)与用户级协程(goroutine)之间构建了一套隐式但严格的协作协议,该协议深刻影响着信号(signal)的接收、分发与处理行为。与传统 C 程序不同,Go 并不将信号直接投递到任意 goroutine,而是由运行时统一接管,并依据信号类型与当前调度状态决定其最终归宿。
信号的捕获与路由规则
SIGQUIT、SIGINT、SIGTERM等终止类信号默认由主 goroutine(即main.main所在的 goroutine)同步接收;SIGUSR1和SIGUSR2可被任意 goroutine 通过signal.Notify显式注册监听,但实际投递仍由运行时确保线程安全;SIGPIPE默认被忽略(SIG_IGN),避免因写关闭管道触发 panic;SIGCHLD、SIGHUP等系统信号由运行时内部专用 M(machine)线程处理,不暴露给用户 goroutine。
协程阻塞对信号处理的影响
当主 goroutine 处于非抢占点(如 syscall.Syscall、runtime.gopark 或长时间循环)时,信号可能延迟送达。以下代码演示如何确保 SIGINT 被及时响应:
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// 创建通道接收指定信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("Press Ctrl+C to exit...")
select {
case sig := <-sigCh:
fmt.Printf("Received signal: %v\n", sig)
case <-time.After(30 * time.Second):
fmt.Println("Timeout reached.")
}
}
此例中,
signal.Notify将信号转发至带缓冲的 channel,使主 goroutine 在select中可非阻塞等待;若未调用Notify,SIGINT将直接触发默认行为(程序退出),无法被拦截。
关键约束表
| 行为 | 是否允许 | 说明 |
|---|---|---|
| 在多个 goroutine 中 Notify 同一信号 | ✅ | 运行时自动去重并广播 |
| 在 syscall.Syscall 中接收 SIGUSR1 | ❌ | 可能丢失或延迟,应改用 signal.Notify + 非阻塞 I/O |
使用 signal.Ignore 禁用 SIGQUIT |
❌ | Go 运行时强制接管,忽略无效 |
第二章:Go运行时信号模型的隐式约束
2.1 Go信号多路复用机制与M:G:P调度器的耦合关系
Go 运行时将 netpoll(基于 epoll/kqueue/IOCP)与 M:G:P 调度深度协同,实现无阻塞 I/O 与协程调度的无缝衔接。
数据同步机制
当网络文件描述符就绪,netpoll 通过 runtime_pollWait 唤醒关联的 G,该 G 被直接注入 P 的本地运行队列,跳过全局队列竞争:
// src/runtime/netpoll.go
func netpoll(delay int64) gList {
// 遍历就绪事件,取出绑定的 goroutine(g)
for _, ev := range waitEvents() {
gp := (*g)(ev.Data)
list.push(gp) // 直接入P本地队列,非全局队列
}
return list
}
ev.Data存储的是g的指针;list.push(gp)绕过runqputglobal(),避免锁竞争,体现 M:G:P 中 P 作为调度本地化枢纽的核心作用。
调度路径对比
| 触发源 | 调度路径 | 是否涉及 M 阻塞 |
|---|---|---|
| 系统调用返回 | M → P → G(直接唤醒) | 否 |
| 定时器超时 | timerproc → netpoll → G 入 P 队列 | 否 |
| 普通 channel 操作 | 可能触发 gopark → 全局队列 |
是(间接) |
graph TD
A[netpoll 就绪事件] --> B{绑定 G 是否在 P 上?}
B -->|是| C[直接 push 到 P.runq]
B -->|否| D[通过 runqgetp 唤醒并迁移]
C --> E[G 被 M 抢占执行]
D --> E
2.2 SIGCHLD在非主goroutine中被忽略的系统级原因剖析
内核信号投递的线程绑定机制
Linux内核将SIGCHLD默认投递给进程的主线程(即初始线程,TID = PID),而非任意goroutine所绑定的OS线程。Go运行时启动的非主goroutine通常运行在由runtime·mstart创建的辅助OS线程上,这些线程未显式调用sigprocmask或pthread_sigmask接管SIGCHLD,导致信号被内核静默丢弃。
Go运行时的信号屏蔽策略
// runtime/signal_unix.go 片段(简化)
func setsigstack() {
// 主goroutine(即main goroutine)的M会调用此函数注册信号处理
// 非主goroutine的M默认不调用,故SIGCHLD无法被捕获
}
该函数仅在主线程初始化时执行,使主M能接收SIGCHLD;其他M线程的sa_mask未包含SIGCHLD,且未设置SA_RESTART与SA_NOCLDWAIT,造成子进程状态变更事件丢失。
关键差异对比
| 属性 | 主goroutine对应OS线程 | 非主goroutine对应OS线程 |
|---|---|---|
sigprocmask(SIG_BLOCK, &mask)调用 |
✅ 显式启用SIGCHLD |
❌ 未调用,保持默认屏蔽 |
sigaction(SIGCHLD, ...)注册 |
✅ 由initsig完成 |
❌ 无注册,使用默认行为(忽略) |
是否参与runtime.sigtramp调度 |
✅ 是 | ❌ 否 |
graph TD
A[子进程终止] --> B{内核查找目标线程}
B -->|TID == PID| C[投递至主线程]
B -->|TID ≠ PID| D[检查目标线程sigmask]
D -->|SIGCHLD未unblock| E[静默丢弃]
D -->|SIGCHLD已unblock| F[入队等待处理]
2.3 runtime.LockOSThread对信号掩码(sigmask)的无效性实证
runtime.LockOSThread() 仅绑定 Goroutine 到当前 OS 线程,不继承也不同步 pthread sigmask。
信号掩码隔离失效示例
package main
import (
"os/exec"
"runtime"
"syscall"
"time"
)
func main() {
runtime.LockOSThread()
// 在锁定线程后调用 sigprocmask —— 但 Go 运行时未暴露该 syscall 封装
// 实际上,Go 的 signal mask 由 runtime 初始化后固定,用户无法安全修改
cmd := exec.Command("sh", "-c", "kill -USR1 $$; sleep 0.1")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Run()
time.Sleep(time.Second) // 观察是否被 USR1 中断(会!)
}
逻辑分析:
LockOSThread不影响底层线程的sigprocmask(2)状态;Go 运行时在mstart阶段已设置默认信号掩码(屏蔽SIGPIPE等),此后用户级pthread_sigmask调用无法穿透 runtime 管理层。
关键事实对比
| 行为 | 是否受 LockOSThread 影响 |
说明 |
|---|---|---|
| Goroutine 与 M 的绑定 | ✅ | 确保调度器不迁移该 Goroutine |
当前线程的 sigmask |
❌ | 完全独立,Go runtime 不提供读写接口 |
signal.Notify 注册 |
⚠️ | 仅作用于 Go 信号处理器,不修改 OS 层掩码 |
graph TD
A[Goroutine 调用 LockOSThread] --> B[绑定至当前 M]
B --> C[但 sigmask 仍由 runtime init 固定]
C --> D[用户调用 pthread_sigmask 无效果]
2.4 从strace和gdb跟踪看SIGCHLD丢失的完整调用链
当父进程未及时处理子进程退出,SIGCHLD 可能被内核丢弃——尤其在 SA_RESTART 与 waitpid(-1, ..., WNOHANG) 混用时。
strace 观察关键信号流
strace -e trace=clone,waitpid,kill,sigreturn -f ./parent
→ 显示 clone() 后无对应 waitpid() 调用,且 sigreturn 返回前 SIGCHLD 已被覆盖。
gdb 中定位信号掩码竞态
// 在 sigaction 设置处下断点
(gdb) p/x $rdi // sa_handler 地址
(gdb) p/x *(sigset_t*)$rsi // sa_mask:若含 SIGCHLD,则阻塞期间子退出将导致信号合并丢失
sa_mask 若静态包含 SIGCHLD,且子进程在阻塞窗口内快速退出,仅保留一个待决位,后续 SIGCHLD 被丢弃。
关键机制对比
| 场景 | SIGCHLD 是否可丢失 | 原因 |
|---|---|---|
signal(SIGCHLD, handler) + waitpid(..., WNOHANG) 循环 |
否 | 每次只消费一个,但无阻塞窗口 |
sigaction(..., SA_RESTART \| SA_BLOCK) + pause() |
是 | 阻塞期间多子退出 → 仅一个信号入队 |
graph TD
A[子进程exit_group] --> B[内核检查父进程sigmask]
B --> C{SIGCHLD是否被阻塞?}
C -->|是| D[加入pending队列<br>(单bit,不排队)]
C -->|否| E[立即递送]
D --> F[后续同信号到来 → 丢弃]
2.5 多线程环境下SIGCHLD投递目标的不确定性实验验证
在多线程进程中,SIGCHLD 的投递目标并非固定于某一线程,而是由内核随机选择一个未阻塞该信号的线程进行递达——这一行为在 POSIX 标准中明确允许,但常被开发者忽略。
实验设计要点
- 主线程调用
sigprocmask()阻塞SIGCHLD - 两个工作线程分别调用
sigwait()监听SIGCHLD - 子进程退出后,观察哪个线程实际收到信号
// 线程信号监听逻辑(简化)
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
pthread_sigmask(SIG_BLOCK, &set, NULL); // 主线程屏蔽
// 工作线程A/B均执行:
sigwait(&set, &sig); // 阻塞等待,但仅一个能返回
逻辑分析:
sigwait()要求调用线程已预先屏蔽目标信号;若多个线程同时等待同一信号,内核仅唤醒其中一个(无序、不可预测)。参数&set必须与pthread_sigmask()中屏蔽集严格一致,否则sigwait()返回EINVAL。
关键现象对比
| 线程状态组合 | SIGCHLD 投递确定性 | 原因 |
|---|---|---|
| 仅1线程 unblock+wait | 确定 | 唯一候选者 |
| 2线程均 unblock+wait | 不确定 | 内核随机选择(无FIFO保证) |
| 全部线程 blocked | 挂起,不投递 | 无接收者,信号暂存队列 |
graph TD
A[子进程exit] --> B{内核查找可投递线程}
B --> C[线程1:SIGCHLD未屏蔽?]
B --> D[线程2:SIGCHLD未屏蔽?]
C -->|是| E[可能投递]
D -->|是| E
E --> F[仅一个线程实际唤醒]
第三章:协程安全信号处理的正确范式
3.1 使用signal.Notify配合单例goroutine进行集中分发
核心设计思想
将信号监听与业务处理解耦:signal.Notify仅负责捕获信号,由唯一长期运行的 goroutine 统一接收、过滤并分发至各注册处理器。
信号分发器结构
type SignalDispatcher struct {
sigCh chan os.Signal
mu sync.RWMutex
handlers map[os.Signal][]func()
}
func NewSignalDispatcher() *SignalDispatcher {
d := &SignalDispatcher{
sigCh: make(chan os.Signal, 1),
handlers: make(map[os.Signal][]func()),
}
signal.Notify(d.sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
go d.dispatchLoop() // 启动单例分发goroutine
return d
}
逻辑分析:
sigCh缓冲大小为1,避免信号丢失;dispatchLoop阻塞读取并串行调用所有匹配处理器,确保事件顺序性与并发安全。os.Signal作为键支持多处理器注册。
注册与分发流程
| 步骤 | 操作 |
|---|---|
| 1 | 调用 Register(os.Interrupt, cleanupDB) 添加处理器 |
| 2 | 信号到达时,dispatchLoop 从 sigCh 读取信号值 |
| 3 | 遍历对应 handlers[signal] 切片,同步依次执行 |
graph TD
A[signal.Notify] --> B[单例dispatchLoop]
B --> C{匹配handlers}
C --> D[func1()]
C --> E[func2()]
C --> F[...]
3.2 基于os/exec.Cmd.WaitPID与syscall.WaitStatus的子进程生命周期同步
数据同步机制
os/exec.Cmd.Wait() 内部调用 WaitPID 获取子进程退出状态,其底层依赖 syscall.Wait4(Linux)或 Waitpid(Unix),返回 *syscall.WaitStatus 接口实例。
cmd := exec.Command("sleep", "1")
_ = cmd.Start()
_, err := cmd.Process.Wait() // 阻塞至子进程终止
if err != nil {
log.Fatal(err)
}
该调用触发内核 wait4() 系统调用,阻塞当前 goroutine 直至子进程状态变更;err 包含 *exec.ExitError,其 Sys() 方法可断言为 syscall.WaitStatus。
状态解析能力
syscall.WaitStatus 提供细粒度退出元信息:
| 方法 | 含义 | 典型值 |
|---|---|---|
ExitStatus() |
正常退出码 | –255 |
Signaled() |
是否被信号终止 | true(如 SIGKILL) |
Signal() |
终止信号编号 | syscall.SIGTERM(15) |
生命周期控制流
graph TD
A[Start()] --> B[Process.Pid 分配]
B --> C[WaitPID 阻塞等待]
C --> D{子进程终止?}
D -->|是| E[填充 WaitStatus]
D -->|否| C
E --> F[返回 exitCode / signal]
3.3 在CGO边界外规避SIGCHLD依赖的工程替代方案
当 Go 程序通过 CGO 调用 C 库启动子进程时,SIGCHLD 信号可能被 C 运行时接管,导致 Go 的 os/exec 无法可靠回收僵尸进程。
主动轮询替代信号通知
使用 waitpid(-1, WNOHANG) 非阻塞轮询,避免依赖内核信号分发:
// cgo_poll_child.c
#include <sys/wait.h>
#include <unistd.h>
int poll_zombie(pid_t pid) {
int status;
pid_t ret = waitpid(pid, &status, WNOHANG); // WNOHANG:不阻塞;pid:精确等待指定子进程
return (ret == pid) ? status : -1; // 成功返回退出码,-1 表示仍在运行或已失联
}
逻辑分析:
waitpid直接查询内核进程表,绕过信号队列。WNOHANG确保调用即时返回,适配 Go goroutine 并发轮询场景;参数pid显式限定目标,避免误收其他子进程状态。
可选策略对比
| 方案 | 实时性 | 线程安全 | CGO 侵入性 | 适用场景 |
|---|---|---|---|---|
SIGCHLD handler |
高 | 低 | 高 | 纯 C 子系统 |
waitpid 轮询 |
中 | 高 | 中 | CGO+Go 混合进程 |
pidfd_open (Linux 5.3+) |
高 | 高 | 低 | 现代 Linux 容器 |
流程协同示意
graph TD
A[Go 启动子进程] --> B[记录 pid + 启动 goroutine]
B --> C{定期调用 waitpid}
C -->|ret == pid| D[清理资源]
C -->|ret == 0| E[继续等待]
C -->|ret == -1| F[超时/忽略]
第四章:典型误用场景与高危修复实践
4.1 在goroutine中直接调用signal.Ignore(SIGCHLD)导致的竞态失效
问题根源:信号处理注册的全局性与goroutine局部性冲突
signal.Ignore 修改的是进程级信号处置行为,而非goroutine局部状态。在新goroutine中调用它,既无法影响已启动的子进程的SIGCHLD投递时机,也无法保证在子进程fork前完成设置。
典型错误模式
go func() {
signal.Ignore(unix.SIGCHLD) // ❌ 延迟注册,竞态窗口存在
cmd := exec.Command("sleep", "1")
cmd.Start()
// 子进程可能已在Ignore生效前退出并触发SIGCHLD
}()
逻辑分析:
signal.Ignore底层调用rt_sigprocmask和sigaction,作用于整个进程。但若cmd.Start()在Ignore执行前已完成fork+exec,且子进程快速退出,则内核立即向父进程发送SIGCHLD——此时忽略规则尚未生效,导致默认终止行为(产生僵尸进程)或被其他handler捕获。
正确实践对比
| 方式 | 时机 | 安全性 | 说明 |
|---|---|---|---|
init() 中调用 signal.Ignore |
进程启动早期 | ✅ 高 | 确保所有后续fork均受控 |
main() 开头调用 |
主goroutine起始 | ✅ 中高 | 仍需确保无goroutine提前spawn子进程 |
| goroutine内调用 | 动态、延迟 | ❌ 低 | 存在不可控竞态窗口 |
graph TD
A[main goroutine] --> B[fork子进程]
B --> C{子进程是否已退出?}
C -->|是| D[内核投递SIGCHLD]
C -->|否| E[goroutine执行signal.Ignore]
D --> F[未忽略→僵尸/panic]
4.2 错误假设runtime.LockOSThread可绑定信号接收线程的调试复现
Go 运行时无法保证 SIGUSR1 等信号仅由调用 runtime.LockOSThread() 的 goroutine 所在 OS 线程接收——信号投递目标由内核决定,与 Go 的线程绑定无关。
信号接收的不可控性
- Linux 将未阻塞的信号随机投递给进程内任意未屏蔽该信号的线程
LockOSThread()仅防止 goroutine 被调度到其他线程,不修改线程的信号掩码或注册信号处理器
复现代码示例
func main() {
runtime.LockOSThread()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR1)
fmt.Println("PID:", os.Getpid())
<-sigs // 此处可能永远阻塞:信号可能被其他 M 抢收
}
signal.Notify内部将信号处理器注册到整个进程,但接收通道sigs绑定在当前 goroutine。若信号被其他 OS 线程(如 GC helper thread)接收且未转发,通道将永不触发。
| 关键行为 | 实际效果 |
|---|---|
LockOSThread() |
固定 goroutine 与 M 的绑定 |
signal.Notify() |
全局注册,但接收依赖 goroutine 所在 P/M 状态 |
graph TD
A[main goroutine] -->|LockOSThread| B[OS Thread T1]
C[GC helper M] --> D[OS Thread T2]
E[Kernel signal delivery] -->|random| B
E -->|random| D
D -->|未转发| F[信号丢失]
4.3 使用chan os.Signal传递SIGCHLD时因缓冲区溢出引发的信号丢失
os.Signal 通道若未设置足够缓冲,高频子进程退出将导致 SIGCHLD 丢失——因 Go 运行时仅发送一次信号,且不重试。
信号丢失复现场景
- 父进程启动 100 个短命子进程(如
sleep 0.01) - 使用
signal.Notify(c, syscall.SIGCHLD)监听,但c := make(chan os.Signal, 1) - 实测约 12–18% 的
SIGCHLD未被接收
关键代码与分析
c := make(chan os.Signal, 1) // ❌ 缓冲区过小,易丢信号
signal.Notify(c, syscall.SIGCHLD)
for range c {
// waitpid() 批量收割,但漏收的信号无法补获
}
make(chan os.Signal, 1) 仅容纳单次 SIGCHLD;并发退出时后续信号被静默丢弃(无阻塞、无通知)。
推荐缓冲策略对比
| 缓冲容量 | 适用场景 | 安全性 |
|---|---|---|
| 1 | 单子进程/极低频退出 | ⚠️ 风险高 |
runtime.NumCPU() |
中等并发(≤50 子进程) | ✅ 平衡 |
| 128 | 高频 fork/exec 场景 | ✅ 推荐 |
正确初始化方式
c := make(chan os.Signal, 128) // ✅ 预留冗余空间
signal.Notify(c, syscall.SIGCHLD)
缓冲区设为 128 可覆盖典型突发负载,避免信号队列溢出。Go 不提供信号积压重放机制,预防即唯一解法。
4.4 混合使用cgo与fork/exec时SIGCHLD处理时机错位的修复案例
问题根源
Go 运行时接管了 SIGCHLD 信号,但 cgo 调用的 C 代码中 fork/exec 启动的子进程退出时,Go 的 runtime.sigtramp 可能尚未完成信号分发,导致 waitpid(-1, &status, WNOHANG) 在 Go 侧调用失败或返回 ECHILD。
关键修复策略
- 在 cgo 初始化阶段显式屏蔽
SIGCHLD(sigprocmask); - 由 C 侧专用线程调用
sigwait同步捕获,并通过管道通知 Go 主 goroutine; - Go 侧避免直接调用
syscall.Wait4,改用通道接收子进程 PID/状态。
修复后信号流
graph TD
A[C fork/exec子进程] --> B[内核发送SIGCHLD]
B --> C{C线程 sigwait}
C --> D[写入pipe fd]
D --> E[Go goroutine read]
E --> F[安全调用 waitpid]
核心代码片段
// cgo_init.c
#include <signal.h>
#include <unistd.h>
static int sigchld_pipe[2];
void init_sigchld_handler() {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
sigprocmask(SIG_BLOCK, &set, NULL); // 阻塞,交由专用线程处理
pipe(sigchld_pipe);
// ... 启动 sigchld_monitor 线程
}
sigprocmask(SIG_BLOCK, &set, NULL) 确保 SIGCHLD 不被 Go 运行时默认 handler 截获,pipe 实现跨语言异步通知,规避信号处理竞态。
第五章:超越SIGCHLD:Go信号治理的演进方向
从阻塞式信号处理到异步事件总线
Go 1.16 引入 os/signal.NotifyContext 后,大量生产系统开始重构信号流。某云原生日志网关(QPS 120K+)将原先基于 signal.Notify + 全局 channel 的阻塞模型,替换为 NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)。改造后,服务在 Kubernetes Pod 被 kubectl delete 时,平均优雅退出耗时从 3.2s 降至 487ms——关键在于 ctx 取消自动触发 goroutine 清理链,避免了手动广播 cancel channel 的竞态风险。
基于信号的配置热重载实践
某微服务集群采用 syscall.SIGHUP 触发配置重载,但早期实现存在严重缺陷:
// ❌ 危险模式:未隔离信号处理与业务逻辑
signal.Notify(ch, syscall.SIGHUP)
go func() {
for range ch {
reloadConfig() // 阻塞调用,可能卡住信号接收
}
}()
升级后采用管道解耦:
| 组件 | 职责 | SLA保障机制 |
|---|---|---|
| Signal Dispatcher | 仅接收并转发 SIGHUP 到 ring buffer | 使用 sync.Pool 复用 buffer,避免 GC 峰值 |
| Config Loader | 从 ring buffer 拉取事件,执行原子切换 | 加入 atomic.CompareAndSwapPointer 版本校验 |
| Validator Goroutine | 异步校验新配置有效性 | 超时 500ms 自动回滚至上一版 |
多信号协同的生命周期编排
某边缘计算框架需响应 SIGUSR1(调试模式开关)、SIGUSR2(内存快照触发)、SIGTERM(终止)三类信号,并保证执行顺序严格:
flowchart LR
A[收到 SIGUSR1] --> B{当前是否运行中?}
B -->|是| C[暂停工作 goroutine]
B -->|否| D[启动调试监听器]
E[收到 SIGUSR2] --> F[触发 runtime.GC\n+ pprof.WriteHeapProfile]
G[收到 SIGTERM] --> H[按优先级关闭:<br/>1. HTTP server<br/>2. MQTT client<br/>3. local DB]
该设计通过 signal.Ignore(syscall.SIGUSR1, syscall.SIGUSR2) 在非主 goroutine 中显式屏蔽信号,确保只有主循环具备调度权。
信号与 Context 的深度集成
在 gRPC 服务中,syscall.SIGINT 不再简单调用 grpcServer.Stop(),而是注入自定义 SignalCanceler:
type SignalCanceler struct {
mu sync.RWMutex
cancel context.CancelFunc
sigCh chan os.Signal
}
func (s *SignalCanceler) Start() {
s.sigCh = make(chan os.Signal, 1)
signal.Notify(s.sigCh, syscall.SIGINT, syscall.SIGTERM)
go s.watch()
}
func (s *SignalCanceler) watch() {
select {
case <-s.sigCh:
s.mu.Lock()
if s.cancel != nil {
s.cancel()
}
s.mu.Unlock()
// 立即触发 grpcServer.GracefulStop(),不等待超时
grpcServer.GracefulStop()
}
}
该模式使服务在 CI/CD 流水线中可被 timeout 30s ./service 精确控制生命周期,失败率下降 92%。
内核级信号优化验证
在 Linux 5.10+ 环境下,通过 /proc/sys/kernel/ns_last_pid 监控 PID namespace 泄漏,发现旧版信号处理导致 fork() 子进程残留。启用 runtime.LockOSThread() + syscall.Sigaction 手动注册 SA_RESTART 标志后,72 小时压测中子进程泄漏数从 142 降至 0。
