1. 为什么要在Go里折腾命名管道
第一次接触命名管道是在做一个日志采集模块的时候。当时的需求很直接:一个常驻的采集进程负责从多个来源收数据,另一个分析进程需要实时拿到这些数据做统计。最直觉的方案是上消息队列,但部署环境里没有现成的中间件,引入一套又太重。也考虑过Unix Domain Socket,但那个需要自己定义协议、处理连接生命周期,代码量不小。后来想到命名管道这个东西——操作系统层面就支持,不需要额外依赖,读写就像操作普通文件一样简单,对于这种"一个写、一个读"的流式场景简直太合适了。
命名管道(Named Pipe),在类Unix系统上叫FIFO(First In First Out),在Windows上叫Named Pipe,本质上是内核提供的一块缓冲区,通过文件系统路径来标识。两个不相关的进程只要知道这个路径,就能一个往里写、一个从里读,内核负责同步和缓冲。它跟匿名管道最大的区别就是有名字,所以可以跨进程、跨终端使用,不局限于父子进程之间。
Go语言标准库里的os包对FIFO的支持其实已经够用了,syscall.Mkfifo创建,os.OpenFile打开,然后就是普通的Read和Write。但真正用起来,坑比想象中多:打开时会阻塞、读写端关闭后的行为、缓冲区大小对性能的影响、多个写者时的数据交错问题……这些细节如果不在项目里踩一遍,光看文档是体会不到的。
这篇文章适合两类人:一类是已经在用Go做系统编程,想找一个轻量级IPC方案的;另一类是对操作系统底层机制感兴趣,想通过一个具体案例把管道通信搞明白的。我会从设计思路讲到代码实现,再到实际踩过的坑,尽量把每个决策背后的原因说清楚。代码可以直接拿去改改用,但更重要的是理解为什么这么写。
2. 命名管道的核心机制与方案选型
2.1 命名管道到底是个什么东西
要理解命名管道,先得理解匿名管道。你在shell里敲ls | grep go,那个竖线就是匿名管道,它把ls的标准输出接到grep的标准输入。匿名管道由内核创建,没有文件系统入口,只存在于创建它的进程及其子进程之间。一旦这些进程退出,管道就消失了。
命名管道则是在文件系统里有一个实实在在的路径,比如/tmp/my_pipe。你用ls -l看它,会显示类型是p(pipe)。任何有权限的进程都可以打开这个路径进行读写。内核为这个路径维护一个缓冲区,写进程往里面塞数据,读进程从里面取数据。当缓冲区满时,写操作会阻塞;当缓冲区空时,读操作会阻塞。这就是所谓的"阻塞式IO",也是管道通信最基本的同步机制。
在Linux上,命名管道的缓冲区大小默认是64KB(可以通过fcntl的F_SETPIPE_SZ调整,但上限受/proc/sys/fs/pipe-max-size限制)。这个大小直接影响吞吐量:如果写入速度远大于读取速度,写进程会频繁阻塞,整体吞吐就上不去。反过来,如果缓冲区太大,数据在管道里积压,实时性又会下降。所以选型时得根据实际数据速率来权衡。
Windows上的命名管道机制略有不同,它更接近Socket的概念,有服务端和客户端的角色区分,支持双向通信和消息模式。但Go的os包在Windows上对命名管道的抽象和Unix不太一样,跨平台代码需要做条件编译。本文主要聚焦类Unix环境,Windows的差异会在后面提一下。
2.2 为什么不用其他IPC方案
做进程间通信,可选方案其实不少,我把当时考虑过的几个列出来对比一下:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 命名管道 | 内核原生支持,无需额外依赖,API简单 | 单向流式,无消息边界,跨平台差异大 | 日志采集、数据流转发 |
| Unix Domain Socket | 支持双向、有连接概念、可传文件描述符 | 需要定义协议,代码量较大 | 需要请求-响应模式的场景 |
| 共享内存 | 速度最快,零拷贝 | 需要自己处理同步,复杂度高 | 大数据量、低延迟场景 |
| 消息队列 | 有消息边界,支持多对多 | 需要额外中间件,运维成本高 | 分布式系统、解耦场景 |
| 临时文件 | 实现最简单 | 轮询开销大,实时性差 | 离线批处理 |
选命名管道的核心理由就一个:够用且简单。我们的场景是单向的、流式的、数据量中等的日志转发,不需要消息边界,不需要双向通信,不需要多对多。命名管道刚好匹配这些约束,代码量最少,出问题的概率也最低。
这里有个经验:技术选型时,"够用"比"先进"重要得多。我见过太多项目为了用上某个时髦的中间件,引入了大量不必要的复杂度,最后维护成本远超收益。命名管道这种"老古董"技术,在特定场景下反而是最优解。
2.3 Go标准库对FIFO的支持现状
Go的os包没有直接提供创建FIFO的函数,需要用syscall.Mkfifo。这个函数在golang.org/x/sys/unix包里也有更完整的封装。打开FIFO用os.OpenFile,但要注意打开模式:
os.O_RDONLY:只读打开,会阻塞直到有写者打开os.O_WRONLY:只写打开,会阻塞直到有读者打开os.O_RDWR:读写打开,不会阻塞(但语义上不太规范)- 加上
syscall.O_NONBLOCK:非阻塞打开,但后续读写行为会变化
这个阻塞特性是新手最容易踩的坑。你写个测试程序,先os.OpenFile(path, os.O_RDONLY, 0),程序就卡在那里不动了,因为还没有写者打开同一个管道。很多人以为是代码写错了,其实是内核的同步机制在起作用。
Go的os.File对FIFO的读写就是普通的文件IO,没有特殊处理。这意味着Read调用会阻塞直到有数据可读,Write调用会阻塞直到缓冲区有空闲。如果你需要非阻塞或超时控制,得自己用goroutine加channel来封装,或者用syscall.SetNonblock配合select/epoll。标准库的os.File在Linux上底层用的是epoll(通过网络轮询器),所以其实可以用SetReadDeadline来做超时,但FIFO是否支持这个特性需要实测确认。
3. 从零实现一个可靠的管道通信模块
3.1 整体架构设计
我们的目标是实现一个模块,包含两个角色:Writer(写端)和Reader(读端)。Writer负责把数据写入管道,Reader负责从管道读取数据并处理。两者可以运行在同一个进程的不同goroutine里,也可以是完全独立的进程。
设计上我定了几个原则:
第一,创建和打开分离。管道的创建(Mkfifo)只做一次,由启动脚本或初始化代码负责。如果管道已存在,Mkfifo会返回EEXIST错误,这个要正确处理,不能当成致命错误。
第二,读写端各自独立重试。管道文件可能被删除或重建,读写端需要能感知并重新打开。但重试不能太频繁,否则会打满CPU。
第三,数据帧要有边界。命名管道是字节流,没有消息边界。如果Writer一次写100字节,Reader可能分两次读到60和40字节。所以需要在应用层定义帧格式,比如"长度前缀+数据"。
第四,优雅关闭。进程退出时要关闭文件描述符,否则管道可能残留。但关闭时机要小心,写端关闭后读端会读到EOF,读端关闭后写端会收到SIGPIPE信号(默认行为是进程终止)。
基于这些原则,我画了一个简单的架构:
Writer进程 --> [命名管道 /tmp/data_pipe] --> Reader进程 | | 帧编码器 帧解码器 | | 数据源 数据处理3.2 创建与打开管道的正确姿势
先看创建管道的代码:
package pipe import ( "fmt" "os" "syscall" ) // CreateFifo 创建命名管道,如果已存在则忽略 func CreateFifo(path string) error { err := syscall.Mkfifo(path, 0666) if err != nil { // EEXIST 表示管道已存在,这不是错误 if err == syscall.EEXIST { return nil } return fmt.Errorf("mkfifo %s: %w", path, err) } return nil }权限0666表示所有用户可读写,实际权限还会受umask影响。生产环境建议用更严格的权限,比如0600,只允许特定用户访问。
打开管道的代码要分读写两端:
// OpenWriter 以只写方式打开管道,会阻塞直到有读者 func OpenWriter(path string) (*os.File, error) { return os.OpenFile(path, os.O_WRONLY, 0) } // OpenReader 以只读方式打开管道,会阻塞直到有写者 func OpenReader(path string) (*os.File, error) { return os.OpenFile(path, os.O_RDONLY, 0) }这里有个关键点:打开时的阻塞是内核行为,无法通过Go的context取消。如果你在goroutine里调用OpenWriter,而这个goroutine需要能被取消,那就麻烦了。解决方案是用O_NONBLOCK打开,然后用select轮询:
// OpenWriterNonblock 非阻塞打开写端 func OpenWriterNonblock(path string) (*os.File, error) { fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, err } // 打开成功后,把非阻塞标志去掉,后续读写还是阻塞模式 if err := syscall.SetNonblock(fd, false); err != nil { syscall.Close(fd) return nil, err } return os.NewFile(uintptr(fd), path), nil }但非阻塞打开写端时,如果没有读者,会返回ENXIO错误。所以实际使用时,通常是在循环里重试,配合短暂的sleep。
实操心得:在容器环境里,命名管道的路径最好放在挂载的volume里,不要放在容器内部的
/tmp。否则容器重启后管道就没了,而外部进程可能还在等这个路径。我们当时就踩过这个坑,排查了半天才发现是容器重建导致管道丢失。
3.3 数据帧的编码与解码
前面说了,管道是字节流,没有消息边界。假设Writer写了"hello"和"world"两条消息,Reader可能一次读到"helloworld",也可能分十次读到单个字符。所以必须自己定义帧格式。
我用的格式很简单:4字节大端长度前缀 + 数据体。长度前缀表示数据体的字节数,不包括前缀本身。
import ( "encoding/binary" "io" ) const MaxFrameSize = 1024 * 1024 // 单帧最大1MB,防止恶意长度 // WriteFrame 写入一个数据帧 func WriteFrame(w io.Writer, data []byte) error { if len(data) > MaxFrameSize { return fmt.Errorf("frame too large: %d", len(data)) } var header [4]byte binary.BigEndian.PutUint32(header[:], uint32(len(data))) if _, err := w.Write(header[:]); err != nil { return err } _, err := w.Write(data) return err } // ReadFrame 读取一个数据帧 func ReadFrame(r io.Reader) ([]byte, error) { var header [4]byte if _, err := io.ReadFull(r, header[:]); err != nil { return nil, err } length := binary.BigEndian.Uint32(header[:]) if length > MaxFrameSize { return nil, fmt.Errorf("frame too large: %d", length) } data := make([]byte, length) if _, err := io.ReadFull(r, data); err != nil { return nil, err } return data, nil }这里用io.ReadFull而不是r.Read,因为Read可能只读到部分数据。io.ReadFull会一直读到填满缓冲区或出错为止,这正是我们需要的。
MaxFrameSize这个限制很重要。如果没有它,一个恶意的或出bug的Writer发送一个长度为0xFFFFFFFF的帧头,Reader就会尝试分配4GB内存,直接OOM。这种防御性编程在进程间通信里是必须的。
注意:长度前缀用大端还是小端其实无所谓,只要两端一致就行。但网络协议里通常用大端(网络字节序),所以这里也用了大端,方便以后改成网络传输。
3.4 读写循环与错误处理
Writer的主循环大概长这样:
func RunWriter(path string, dataCh <-chan []byte) error { for { f, err := OpenWriter(path) if err != nil { log.Printf("open writer failed: %v, retrying...", err) time.Sleep(time.Second) continue } log.Printf("writer opened %s", path) err = writeLoop(f, dataCh) f.Close() if err == nil { return nil // 正常结束 } log.Printf("write loop error: %v, reopening...", err) time.Sleep(time.Second) } } func writeLoop(f *os.File, dataCh <-chan []byte) error { for data := range dataCh { if err := WriteFrame(f, data); err != nil { return err } } return nil }Reader的主循环类似,但多了一个EOF处理:
func RunReader(path string, handler func([]byte) error) error { for { f, err := OpenReader(path) if err != nil { log.Printf("open reader failed: %v, retrying...", err) time.Sleep(time.Second) continue } log.Printf("reader opened %s", path) err = readLoop(f, handler) f.Close() if err == io.EOF { log.Printf("writer closed, reopening...") time.Sleep(time.Second) continue } if err != nil { log.Printf("read loop error: %v, reopening...", err) time.Sleep(time.Second) continue } } } func readLoop(f *os.File, handler func([]byte) error) error { for { data, err := ReadFrame(f) if err != nil { return err } if err := handler(data); err != nil { return err } } }这里的关键设计是:任何错误都导致重新打开管道。因为管道可能被外部删除重建,或者写端/读端崩溃导致连接断开。重新打开是最简单的恢复策略。
但有个细节要注意:当所有写端都关闭后,读端的Read会返回io.EOF。这时候如果直接重新打开,会阻塞在OpenReader上,直到有新的写端打开。这个行为是合理的,但日志里会看到"writer closed, reopening..."然后卡住,需要理解这是正常的。
3.5 多写者场景的数据交错问题
如果多个进程同时往同一个管道写数据,会发生什么?POSIX标准规定,小于PIPE_BUF(通常是4096字节)的写操作是原子的,不会交错。但大于PIPE_BUF的写操作可能被拆分,多个写者的数据会交错在一起。
我们的帧格式里,帧头4字节加上数据体,如果总大小超过4096字节,就可能出现交错。比如Writer A写了帧头(4字节),Writer B也写了帧头,然后A写数据体,B写数据体,Reader读到的就是A的头+B的头+A的数据+B的数据,完全乱套。
解决方案有两个:
第一,限制单帧大小不超过PIPE_BUF。这样每次Write都是原子的,不会交错。但1MB的限制就得降到4KB,可能不够用。
第二,用文件锁或外部协调机制保证同一时刻只有一个写者。比如用一个锁文件,写之前先获取锁。但这增加了复杂度。
第三,每个写者用独立的管道。Reader同时读多个管道,用select或goroutine分别处理。这是最干净的方案,但管道数量多了之后管理成本上升。
我们当时的场景是单写者,所以没遇到这个问题。但如果你的场景是多写者,一定要提前考虑。我建议优先考虑方案三,虽然管道多了,但逻辑最简单,不会出现数据交错。
4. 性能调优与常见问题排查
4.1 缓冲区大小对吞吐量的影响
前面提到,Linux下管道缓冲区默认64KB。这个值对吞吐量影响很大。我做过一个简单的测试:Writer以最大速度写入1KB的帧,Reader尽快读取,统计每秒处理的帧数。
| 缓冲区大小 | 吞吐量(帧/秒) | 吞吐量(MB/秒) |
|---|---|---|
| 64KB(默认) | 约120,000 | 约117 |
| 256KB | 约180,000 | 约176 |
| 1MB | 约220,000 | 约215 |
可以看到,增大缓冲区确实能提升吞吐量,但边际效益递减。从64KB到256KB提升了50%,从256KB到1MB只提升了22%。而且缓冲区太大会增加内存占用和延迟。
调整缓冲区大小用fcntl的F_SETPIPE_SZ:
import "golang.org/x/sys/unix" func SetPipeSize(f *os.File, size int) error { return unix.FcntlInt(f.Fd(), unix.F_SETPIPE_SZ, size) }但要注意,非root用户能设置的最大值受/proc/sys/fs/pipe-max-size限制,默认是1MB。超过这个值会返回EPERM。
实操心得:不要盲目调大缓冲区。先测量实际的数据速率和延迟要求,再决定。如果Writer和Reader的处理速度差不多,默认的64KB就够了。只有当Writer明显快于Reader,且能容忍一定延迟时,才考虑调大。
4.2 打开阻塞导致的死锁
这是新手最容易踩的坑。看这段代码:
func main() { // 创建管道 syscall.Mkfifo("/tmp/test_pipe", 0666) // 先打开读端 r, _ := os.OpenFile("/tmp/test_pipe", os.O_RDONLY, 0) defer r.Close() // 再打开写端 w, _ := os.OpenFile("/tmp/test_pipe", os.O_WRONLY, 0) defer w.Close() // ... }这段代码会卡在OpenFile读端那一行,因为还没有写者打开管道。内核要求读写两端都打开后,Open调用才会返回。如果你在同一个进程里先开读端再开写端,就会死锁。
解决方案有三种:
第一,用O_RDWR打开。这样不会阻塞,因为读写端都是自己。但语义上不太规范,而且如果另一个进程也这么干,数据流向就乱了。
第二,用O_NONBLOCK打开。读端非阻塞打开会立即返回,但写端非阻塞打开时如果没有读者会返回ENXIO。所以通常是非阻塞打开读端,然后阻塞打开写端。
第三,在单独的goroutine里打开。这样主goroutine不会被阻塞,可以继续打开另一端。
func main() { syscall.Mkfifo("/tmp/test_pipe", 0666) var w *os.File var err error // 在goroutine里打开写端 done := make(chan struct{}) go func() { w, err = os.OpenFile("/tmp/test_pipe", os.O_WRONLY, 0) close(done) }() // 主goroutine打开读端 r, err := os.OpenFile("/tmp/test_pipe", os.O_RDONLY, 0) if err != nil { log.Fatal(err) } defer r.Close() // 等待写端打开完成 <-done if err != nil { log.Fatal(err) } defer w.Close() // ... }这个模式在实际项目里很常用。但要注意goroutine里的错误处理,别把错误吞了。
4.3 写端关闭后的SIGPIPE问题
当读端关闭后,写端继续写会收到SIGPIPE信号。默认情况下,这个信号会终止进程。在Go里,运行时对SIGPIPE做了特殊处理:如果是在标准输出或标准错误上写,会触发信号;如果是其他文件描述符,Write会返回EPIPE错误而不是触发信号。
但这个行为在不同Go版本和不同平台上可能有差异。保险的做法是显式处理EPIPE错误:
func writeLoop(f *os.File, dataCh <-chan []byte) error { for data := range dataCh { if err := WriteFrame(f, data); err != nil { if errors.Is(err, syscall.EPIPE) { return fmt.Errorf("reader closed: %w", err) } return err } } return nil }另外,可以用signal.Ignore(syscall.SIGPIPE)来忽略这个信号,但Go运行时已经做了处理,通常不需要。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 打开时卡住 | 另一端未打开 | 检查是否有进程打开了另一端 | 用非阻塞打开或goroutine |
| 读到乱码 | 帧格式不一致 | 检查两端的帧编码/解码逻辑 | 统一帧格式,加长度校验 |
| 数据丢失 | 缓冲区溢出 | 检查写入速度是否远大于读取速度 | 调大缓冲区或加流控 |
| 写操作返回EPIPE | 读端已关闭 | 检查读端进程是否退出 | 重新打开管道或退出 |
| 吞吐量低 | 缓冲区太小或帧太小 | 测量实际吞吐量 | 调大缓冲区,合并小帧 |
| 管道文件消失 | 被外部删除 | 检查是否有清理脚本 | 重新创建管道 |
| 权限拒绝 | 管道权限不对 | ls -l查看权限 | 调整权限或umask |
4.5 跨平台兼容性注意事项
Windows上的命名管道和Unix的FIFO差异很大。Windows的命名管道有服务端和客户端的概念,路径格式是\\.\pipe\name,支持双向通信和消息模式。Go的os包在Windows上对命名管道的支持有限,syscall.Mkfifo在Windows上不存在。
如果要做跨平台,通常的做法是:
- Unix平台用FIFO
- Windows平台用
net.Pipe或os.Pipe(匿名管道) - 或者直接用TCP localhost,虽然性能差一点,但跨平台最省心
我们当时的项目只在Linux上部署,所以没做跨平台。但如果你的项目需要,建议把管道操作抽象成接口,不同平台提供不同实现。
type Pipe interface { OpenWriter() (io.WriteCloser, error) OpenReader() (io.ReadCloser, error) Close() error }然后Unix和Windows各实现一套。这样上层逻辑不用改。
5. 实际项目中的经验与扩展思路
5.1 监控与可观测性
管道通信是"黑盒"的,出了问题不好排查。我们在项目里加了几个监控指标:
- 写入帧数/字节数:Writer端统计,定期上报
- 读取帧数/字节数:Reader端统计
- 写入阻塞时间:
Write调用耗时超过阈值的次数 - 读取阻塞时间:
Read调用耗时超过阈值的次数 - 重连次数:管道重新打开的频次
这些指标用Prometheus的client库暴露出来,配合Grafana看板,能快速定位是Writer慢了还是Reader慢了,还是管道本身有问题。
另外,日志里要记录每次打开和关闭管道的事件,包括时间戳和进程ID。这样出问题时能还原时间线。
5.2 流控机制的实现
如果Writer速度远大于Reader,缓冲区会经常满,Writer频繁阻塞。虽然阻塞本身是正常的流控机制,但频繁阻塞会导致Writer的处理逻辑被拖慢。更好的做法是在应用层做流控。
一个简单的方案是:Reader定期向Writer发送"确认"消息,Writer收到确认后才继续发送。但这需要双向通信,命名管道是单向的,得用两个管道。
另一个方案是:Writer维护一个待发送队列,当队列长度超过阈值时,暂停从数据源读取。这需要Writer能控制数据源的速率。
我们当时用的是最简单的方案:让Writer的阻塞成为自然流控。因为数据源本身也是从网络读的,网络速度有限,Writer不会无限快。所以实际运行中,Writer阻塞的时间占比很低,没有成为瓶颈。
但如果你的场景是Writer从磁盘读大量数据往管道写,那就需要考虑流控了。否则Writer会一直阻塞,磁盘IO也浪费了。
5.3 与其他技术的组合使用
命名管道不是孤立的,它可以和其他技术组合:
与goroutine池组合:Reader读到数据后,分发给worker池处理。这样读取和处理解耦,Reader不会因为处理慢而阻塞。
与channel组合:Writer从channel读数据,Reader往channel写数据。channel作为进程内的缓冲,管道作为进程间的通道。
与mmap组合:如果数据量特别大,可以考虑用共享内存+命名管道做通知。管道只传"有数据了"的信号,实际数据放在共享内存里。这样避免了大数据在管道里的拷贝开销。
与systemd组合:在Linux上,可以用systemd的socket activation来管理管道。systemd负责创建管道和启动进程,进程从环境变量里拿到文件描述符。这样进程重启时管道不会丢失。
5.4 一个完整的示例:日志转发
最后给一个完整的、可运行的示例,把前面讲的东西串起来。这个示例包含一个Writer和一个Reader,Writer从标准输入读数据,通过管道转发给Reader,Reader打印到标准输出。
// writer.go package main import ( "bufio" "encoding/binary" "log" "os" "syscall" "time" ) const pipePath = "/tmp/log_pipe" func main() { if err := syscall.Mkfifo(pipePath, 0666); err != nil && err != syscall.EEXIST { log.Fatal(err) } for { f, err := os.OpenFile(pipePath, os.O_WRONLY, 0) if err != nil { log.Printf("open: %v", err) time.Sleep(time.Second) continue } log.Println("writer connected") scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { data := scanner.Bytes() var header [4]byte binary.BigEndian.PutUint32(header[:], uint32(len(data))) if _, err := f.Write(header[:]); err != nil { log.Printf("write header: %v", err) break } if _, err := f.Write(data); err != nil { log.Printf("write data: %v", err) break } } f.Close() if err := scanner.Err(); err != nil { log.Fatal(err) } // 标准输入结束,退出 return } }// reader.go package main import ( "encoding/binary" "io" "log" "os" "time" ) const pipePath = "/tmp/log_pipe" func main() { for { f, err := os.OpenFile(pipePath, os.O_RDONLY, 0) if err != nil { log.Printf("open: %v", err) time.Sleep(time.Second) continue } log.Println("reader connected") for { var header [4]byte if _, err := io.ReadFull(f, header[:]); err != nil { if err != io.EOF { log.Printf("read header: %v", err) } break } length := binary.BigEndian.Uint32(header[:]) data := make([]byte, length) if _, err := io.ReadFull(f, data); err != nil { log.Printf("read data: %v", err) break } os.Stdout.Write(data) os.Stdout.Write([]byte("\n")) } f.Close() } }运行方式:先启动reader,再启动writer,然后在writer的终端里输入文字,reader的终端会实时显示。
这个示例虽然简单,但包含了所有核心要素:管道创建、阻塞打开、帧编码、错误重试、EOF处理。你可以基于它扩展出更复杂的应用。
最后分享一个小技巧:调试管道问题时,可以用
strace跟踪系统调用,看open、read、write的返回值和错误码。比如strace -e trace=open,read,write,openat ./reader,能清楚看到每次调用的参数和结果。这个工具帮我定位过好几次"看起来卡住"的问题,实际上是在等另一端打开。
命名管道这个东西,说简单也简单,说坑多也坑多。关键是要理解它的阻塞语义和字节流特性,然后在应用层做好帧封装和错误恢复。Go的标准库虽然没有专门为FIFO做封装,但用os包加上syscall,足够写出稳定可靠的代码。我在几个项目里用下来,只要把打开阻塞、EOF处理、重连逻辑这三块处理好,基本不会出大问题。