MCP概述
MCP 本质上是一个基于 JSON-RPC 2.0 标准的通信协议,通常运行在 SSE (Server-Sent Events) 或 Stdio (标准输入输出) 传输层之上。
下面我将分三个部分为你深度解析:
- @Tool 注解是如何被"翻译"成 MCP 协议的?(反射与元数据提取)
- MCP 协议长什么样?(JSON-RPC 消息结构拆解)
- 连接与断连的底层握手流程(SSE/Stdio 的生命周期)
一、从 @Tool 到 MCP 协议:底层解析流程
当你在 Spring Boot 中写下 @Tool 注解时,Spring AI 内部发生了一系列"魔法"操作,将 Java 方法变成了 LLM 能理解的 JSON Schema。
1. 扫描与注册 (Startup Phase)
- 扫描器 :Spring 容器启动时,
ToolCallbackProvider(具体实现是BeanToolCallbackProvider) 会扫描所有 Bean。 - 识别 :发现方法上有
@Tool注解。 - 提取元数据 :
- Name : 取
@Tool(name="...")或默认方法名。 - Description : 取
@Tool(description="..."),这是告诉 LLM"什么时候用这个工具"的关键。 - Parameters : 分析方法参数,读取
@ToolParam的描述。
- Name : 取
2. 生成 JSON Schema (Runtime Phase)
Spring AI 使用内部的 ObjectMapper 和类型转换器,将 Java 参数类型转换为 JSON Schema 格式。这是 MCP 协议的核心负载。
Java 代码:
@Tool(name = "get_weather", description = "获取指定城市的天气")
public String getWeather(@ToolParam(description = "城市名称,如 Beijing") String city,
@ToolParam(description = "温度单位,Celsius 或 Fahrenheit") String unit) {
// ...
}
转换后的 JSON Schema (LLM 看到的):
{
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如 Beijing"
},
"unit": {
"type": "string",
"description": "温度单位,Celsius 或 Fahrenheit",
"enum": ["Celsius", "Fahrenheit"]
}
},
"required": ["city", "unit"]
}
3. 封装为 MCP Tool Definition
当 LLM 客户端(Consumer)通过 MCP 协议请求"列出所有可用工具"时,Server 端会将上述信息封装成标准的 MCP 响应:
{
"name": "get_weather",
"description": "获取指定城市的天气",
"inputSchema": {
"type": "object",
"properties": { ... } // 上面生成的 JSON Schema
}
}
结论 :@Tool 注解本身不发送任何东西。是 Spring AI 的反射机制 读取注解,生成 JSON Schema,然后在收到 MCP 协议的 tools/list 请求时,动态构建 JSON 响应发回去。
二、MCP 协议是什么?由什么组成?
MCP 协议没有发明新的轮子,它严格遵循 JSON-RPC 2.0 规范,并定义了一套特定的 Method Names (方法名) 和 Notification (通知)。
1. 核心数据结构 (JSON-RPC 2.0)
所有 MCP 消息都是 JSON 对象,包含以下字段:
jsonrpc: 固定为"2.0"。id: 请求的唯一 ID(用于匹配响应),通知(Notification)没有 ID。method: 调用的方法名(MCP 定义的标准动词)。params: 参数对象。result: 响应结果(仅在 Response 中)。error: 错误信息(仅在 Error Response 中)。
2. MCP 的核心方法集 (Protocol Methods)
MCP 定义了三类核心交互:
表格
| 类别 | 方法名 (Method) | 作用 | 方向 |
|---|---|---|---|
| 初始化 | initialize |
协商协议版本、能力交换 | Client -> Server |
notifications/initialized |
告知初始化完成 | Client -> Server | |
| 工具发现 | tools/list |
获取所有可用工具列表 (含 JSON Schema) | Client -> Server |
| 工具调用 | tools/call |
执行具体工具,传入参数 | Client -> Server |
| 资源/提示 | resources/list |
获取可用数据资源列表 | Client -> Server |
prompts/list |
获取可用提示词模板列表 | Client -> Server |
3. 真实报文示例
场景:Client 请求调用工具
Request (Client -> Server):
{
"jsonrpc": "2.0",
"id": 1001,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"city": "Beijing",
"unit": "Celsius"
}
}
}
Response (Server -> Client):
{
"jsonrpc": "2.0",
"id": 1001,
"result": {
"content": [
{
"type": "text",
"text": "北京今天晴朗,气温 25°C。"
}
],
"isError": false
}
}
三、怎么建连和断连?(Transport Layer)
MCP 协议本身只是 JSON 数据,它需要"交通工具"来传输。Spring AI MCP 主要支持两种传输层:SSE (HTTP) 和 Stdio (进程间通信)。
模式 A:基于 SSE (Server-Sent Events) - 最常用 (Web 场景)
这是 Spring AI MCP WebMvc/WebFlux Starter 默认的模式。
1. 建连流程 (Handshake)
SSE 是非对称 的:客户端发起 HTTP 请求建立长连接,服务端通过这个连接单向推送消息;但客户端要发送消息(如调用工具),通常需要另一个 HTTP POST 通道(或者在较新的 MCP 实现中复用 SSE 连接的双向能力,但在 Spring AI 当前实现中通常是 SSE 接收 + HTTP POST 发送 或 双向 SSE)。
Spring AI 目前的标准 SSE 实现流程:
- Client 发起 SSE 连接 :
- Client 发送
GET /mcp/message(带Accept: text/event-stream)。 - Server 挂起连接,保持 HTTP 200 OK,开始流式传输。
- Client 发送
- Server 推送 Endpoint (可选但常见) :
- 有些实现中,Server 会在 SSE 流中发送一个事件,告诉 Client:"如果你想发给我消息,请 POST 到这个 URL"。
- 注:在 Spring AI 的简化实现中,通常约定同一个 URL 既用于 SSE 接收,也用于 POST 发送,或者通过 Header 区分。
- 协议握手 (JSON-RPC) :
- Client 通过 POST (或 SSE 反向通道) 发送
{"method": "initialize", ...}。 - Server 通过 SSE 流推送
{"method": "initialize", ...}的响应。 - Client 发送
{"method": "notifications/initialized"}(通知,无响应)。 - 连接建立成功 ,开始正常业务交互 (
tools/list,tools/call)。
- Client 通过 POST (或 SSE 反向通道) 发送
2. 断连流程
- 主动断开 :
- Client 关闭 HTTP 连接(停止监听 SSE 流)。
- Server 检测到
ClientAbortException或连接关闭信号,清理会话资源,移除该 Client 注册的临时上下文。
- 心跳检测 :
- MCP 协议本身没有强制的心跳,但底层 HTTP/SSE 通常依赖 TCP Keep-Alive 或应用层定期发送
ping(如果实现了扩展) 来检测死链。
- MCP 协议本身没有强制的心跳,但底层 HTTP/SSE 通常依赖 TCP Keep-Alive 或应用层定期发送
模式 B:基于 Stdio (Standard Input/Output) - 本地 CLI 场景
这是 MCP 原始设计最常用的模式(类似 Python 脚本调用本地工具)。
1. 建连流程
- 启动进程 : Client (如 Claude Desktop 或 Java 应用) 作为一个父进程,通过操作系统命令
fork/exec启动 Server 进程(例如java -jar mcp-server.jar)。 - 绑定管道 :
- Client 将自己的 stdout 连接到 Server 的 stdin。
- Client 将自己的 stdin 连接到 Server 的 stdout。
- 握手 :
- 双方直接通过 stdin/stdout 交换 JSON-RPC 字符串。
- 流程同 SSE (initialize -> initialized)。
2. 断连流程
- 退出信号 : Client 向 Server 进程发送
SIGTERM或SIGKILL。 - EOF: Client 关闭写入端 (stdin),Server 读到 EOF (End Of File),自动退出。
- 优雅退出 : Client 发送 JSON-RPC
notifications/cancelled或自定义退出指令,Server 执行System.exit(0)。
四、总结:底层全链路图解
假设你调用了 chatClient.prompt(...).call() 触发了工具调用:
-
LLM 决策 : OpenAI 返回
function_call: { name: "get_weather", args: {...} }。 -
Spring AI 拦截 :
ChatClient发现需要调用工具,找到对应的ToolCallback(即 MCP Client)。 -
构建 RPC : MCP Client 构建 JSON-RPC 请求:json
{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "get_weather", "arguments": {...} } } -
传输层发送 :
- 如果是 SSE: 通过 WebClient 发起 HTTP POST 请求,Body 是上面的 JSON。
- 如果是 Stdio : 向子进程的 stdin 写入 JSON 字符串 +
\n。
-
Server 端接收 :
- Spring MVC/Flux 控制器接收到 JSON。
McpSyncServer/McpAsyncServer解析 JSON,根据method路由到tools/call处理器。- 查找本地注册的
ToolExecutionService。
-
反射执行 :
- 根据
name("get_weather") 找到对应的 Java Bean 和方法。 - 利用反射 (
Method.invoke) 调用你的@Tool方法,传入参数。
- 根据
-
返回结果 :
- Java 方法返回结果 "25°C"。
- Server 将其封装为 JSON-RPC Response。
- 通过 SSE 流推回给 Client。
-
最终回答 :
- Client 收到结果,再次请求 LLM:"工具返回了 25°C,请回答用户"。
- LLM 生成最终自然语言回复。
MCP进阶--studio
执行过程:
Client (Claude)\] \[操作系统管道 (Pipe)\] \[Spring AI Server (你的代码)
| | |
| 1. 发送 JSON 字符串 | |
| -------------------------> | (写入 stdin) |
| (例如: {"method":...}) | |
| | |
| | <--- Spring 内部线程监听 stdin ---|
| | |
| | 2. 读取一行 JSON |
| | -----------------------------> |
| | |
| | 3. 【反序列化】JSON -> Java 对象 |
| | (识别出这是调用 "add" 工具) |
| | |
| | 4. 【反射调用】你的 @Tool 方法 |
| | -----------------------------> |
| | result = myService.add(1, 2)|
| | <----------------------------- |
| | (拿到返回值 3) |
| | |
| | 5. 【序列化】Java 对象 -> JSON |
| | (把 3 包装成 {"result": 3}...) |
| | |
| | 6. 【关键一步】打印到 stdout |
| | System.out.println(jsonStr) |
| | -----------------------------> | (写入 stdout)
| | |
| 7. 读取返回结果 | <---------------------------- |
| <------------------------- | |
| (收到 {"result": 3}...) | |
注意:
其实studio就是system.out|system.in
system也是跟os文件句柄交互
小区大门和管道(System.out/Socket/File) -> OS 强映射
管道模式:
Java 进程 (Server) \] \[ Client 进程 (Node.js/Python)
(内部) System.out -----(管道 A)-----> (内部) 读取流 (Readable)
(我要发结果给你) (数据流向 -->) (我要收你的结果)
(内部) System.in <-----(管道 B)----- (内部) 写入流 (Writable)
(我要收你指令) (<-- 数据流向) (我要发指令给你)
studio建立管道发送信息
但是他是特殊管道
import java.io.*;
import java.util.Arrays;
public class MyMcpClient {
public static void main(String[] args) {
System.out.println("[Java Client] 准备启动 MCP Server...");
// 【步骤 1】配置启动命令
// 假设你的 jar 包在当前目录,名字叫 my-server.jar
// 如果 java 不在环境变量里,这里要写绝对路径,比如 "C:\\Program Files\\Java\\jdk-17\\bin\\java.exe"
ProcessBuilder pb = new ProcessBuilder("java", "-jar", "my-server.jar");
// 【步骤 2】关键设置!
// 默认情况下 ProcessBuilder 就会创建管道,但为了保险,我们显式地不重定向到继承(除非你想看 Server 的日志直接刷屏)
// 如果你想看 Server 的 System.err 日志直接在当前控制台显示,可以解开下面这行:
// pb.redirectError(ProcessBuilder.Redirect.INHERIT);
try {
// 【步骤 3】启动进程 (魔法发生在这里)
// 此时,操作系统创建了子进程,并建立了管道连接
Process serverProcess = pb.start();
// 【步骤 4】获取通道 (Streams)
// 注意名字的反直觉性:
// serverProcess.getOutputStream() -> 这是写入 Server 的 stdin 的流 (Client 的笔)
// serverProcess.getInputStream() -> 这是读取 Server 的 stdout 的流 (Client 的耳朵)
PrintWriter toServer = new PrintWriter(new OutputStreamWriter(serverProcess.getOutputStream()), true);
BufferedReader fromServer = new BufferedReader(new InputStreamReader(serverProcess.getInputStream()));
// 单独开个线程读取 Server 的错误日志 (System.err),防止阻塞或丢失
new Thread(() -> {
try (BufferedReader errReader = new BufferedReader(new InputStreamReader(serverProcess.getErrorStream()))) {
String line;
while ((line = errReader.readLine()) != null) {
System.err.println("[Server Err] " + line);
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
System.out.println("[Java Client] Server 已启动,正在发送测试指令...");
// 稍微等一下,给 JVM 启动留点时间
Thread.sleep(500);
// 【步骤 5】发送数据 (写入 Server 的 System.in)
String request = "{\"method\": \"initialize\", \"params\": {\"version\": \"1.0\"}}";
toServer.println(request);
// flush 很重要,确保数据立刻推送到管道
toServer.flush();
// 【步骤 6】接收数据 (读取 Server 的 System.out)
String response = fromServer.readLine();
if (response != null) {
System.out.println("[Java Client] 收到 Server 回复: " + response);
} else {
System.out.println("[Java Client] Server 没有返回数据就关闭了。");
}
// 【步骤 7】清理
System.out.println("[Java Client] 测试结束,关闭连接。");
serverProcess.destroy(); // 杀死子进程
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
pb.start();建立连接秘密
慢动作拆解:pb.start() 的微观世界
假设你的 Java Client 正在运行,调用了 pb.start()。此时,CPU 会从 用户态 (User Mode) 切换到 内核态 (Kernel Mode),执行以下步骤:
第 1 步:创建管道 (The Pipes)
操作系统动作 :pipe()
在启动新进程之前 ,Java 告诉操作系统:"我要两根管子。"
操作系统在内核内存中开辟两块缓冲区,并返回 4 个文件描述符 (File Descriptors, FD):
- 管道 A (用于 stdout) :
FD_A_Read(读端)FD_A_Write(写端)
- 管道 B (用于 stdin) :
FD_B_Read(读端)FD_B_Write(写端)
此刻状态:
- Java Client 拿着
FD_A_Read(准备读 Server 的输出)- Java Client 拿着
FD_B_Write(准备写 Server 的输入)- 另外两个端 (
FD_A_Write,FD_B_Read) 暂时悬空,等待分配给未来的子进程。
第 2 步:复制进程 (The Fork)
操作系统动作 :fork() (Linux) / 类似机制 (Windows)
操作系统把当前的 Java Client 进程 完整复制了一份。
- 父进程:原来的 Java Client。
- 子进程 :一个新的、几乎一模一样的进程(此时它还没运行
java -jar,它只是 Client 的克隆体)。
关键点 :子进程继承 了父进程所有的打开文件描述符。
所以,子进程现在手里也有那 4 个 FD:
FD_A_Read,FD_A_Write,FD_B_Read,FD_B_Write。
第 3 步:重定向 (The Redirect) ------ 这是"建连"的核心!
操作系统动作 :dup2() (Duplicate File Descriptor)
在子进程运行真正的程序之前,操作系统对子进程的文件描述符表进行偷梁换柱:
-
处理标准输入 (stdin, FD 0):
- 操作系统关闭子进程原本的 FD 0 (通常是键盘)。
- 操作系统执行
dup2(FD_B_Read, 0)。 - 结果 :子进程的 FD 0 (stdin) 现在指向了 管道 B 的读端。
- 含义:以后子进程想读 stdin,其实是从管道 B 读。
-
处理标准输出 (stdout, FD 1):
- 操作系统关闭子进程原本的 FD 1 (通常是屏幕)。
- 操作系统执行
dup2(FD_A_Write, 1)。 - 结果 :子进程的 FD 1 (stdout) 现在指向了 管道 A 的写端。
- 含义:以后子进程想写 stdout,其实是往管道 A 写。
-
清理多余句柄:
- 关闭子进程中不需要的
FD_A_Read和FD_B_Write(因为子进程不需要读自己的输出,也不需要写自己的输入)。
- 关闭子进程中不需要的
此刻状态:
- 子进程 :
System.in(FD 0) -> 连着FD_B_ReadSystem.out(FD 1) -> 连着FD_A_Write- 父进程 (Client) :
- 拿着
FD_A_Read(等着读)- 拿着
FD_B_Write(准备写)- 管道通了! 就像两根水管接上了。
第 4 步:执行程序 (The Exec)
操作系统动作 :execve()
子进程现在的身份还是"Java Client 的克隆",这没用。
操作系统加载硬盘上的 java.exe (或 bin/java) 二进制文件,覆盖掉子进程当前的内存代码。
- 子进程变成了全新的 Java Server 进程。
- 但是! 刚才重定向的 文件描述符 (FD 0, FD 1) 保持不变 !这是
exec的特性。
奇迹时刻 :
新的 Java Server 启动了。
当 JVM 初始化
System.in时,它去查 FD 0,发现是一个管道读端 。当 JVM 初始化
System.out时,它去查 FD 1,发现是一个管道写端 。
连接完成。
第 5 步:返回 (Return)
pb.start() 方法执行完毕,返回一个 Process 对象给父进程 (Client)。
这个 Process 对象里封装了父进程保留的那两个句柄 (FD_A_Read, FD_B_Write)。
Java Client (父进程) \] \[ 操作系统内核 (Kernel) \] \[ Java Server (子进程)
- 写入数据
clientOut.write("Hi") -----> [ FD_B_Write ] --------(内核缓冲区)--------> [ FD 0 (stdin) ] -----> System.in.read()
(Client 的流) (管道 B 写端) (被重定向过) (Server 读到 "Hi")
- 读取数据
serverIn.read() <----- [ FD 1 (stdout) ] <-------(内核缓冲区)------- [ FD_A_Read ] <----- clientIn.read()
(Server 写到) (被重定向过) (管道 A 读端) (Client 读到结果)
System.out.println()
是不是有点懵,别急
绑定
在调用 pb.start() 之前,Client 进程创建了两个管道。
此时,只有 Client 知道这两个管道的存在。
Pipe_A: Client 持有Read_A和Write_A。Pipe_B: Client 持有Read_B和Write_B。- Server 还不存在。世界上没有任何其他进程持有这些句柄。
- 绑定状态:未建立。
绑定的关键时刻:fork() (遗传)
当 Client 调用 fork() 时,操作系统做了一个动作:复制当前进程的"文件描述符表"(File Descriptor Table)。
- 动作 :OS 创建了一个新进程(我们叫它
Baby)。 - 结果 :
Baby的文件描述符表是 Client 的完美克隆 。Baby的FD 3指向Read_A(和 Client 一样)。Baby的FD 4指向Write_A(和 Client 一样)。Baby的FD 5指向Read_B...
- 绑定含义 :就在这一瞬间,Client 和 Baby 建立了唯一的共享关系。因为它们共同持有了同一组内核对象(管道)。
- 为什么不会连错? 因为
Baby是 Client 复制出来的,它只继承 了 Client 手里的东西。Client 2 手里的管道,Client 1 根本没有,所以Baby也绝对不可能继承到 Client 2 的东西。- 这就是绑定的物理基础:血缘关系(父子进程)。
绑定的固化时刻:dup2() (重定向)
现在 Baby 手里拿着所有的管子头,但它还不知道哪个是输入,哪个是输出。OS 立即执行 dup2 进行强制绑定:
-
指令 :
dup2(Write_B, 0)(把管道 B 的写端,强制映射到标准输入 FD 0) -
指令 :
dup2(Read_A, 1)(把管道 A 的读端,强制映射到标准输出 FD 1) -
动作 :OS 修改了
Baby进程内部的映射表。- 从此,
Baby进程里任何试图读取FD 0(stdin) 的操作,物理上 都被内核 redirect 到了Pipe_B的写端(逻辑上是读端,这里指数据流向的反向,修正:应该是dup2(Read_B, 0)让 Baby 读,dup2(Write_A, 1)让 Baby 写。抱歉,刚才为了简化逻辑可能有点绕,我们修正一下标准流程):
修正后的标准绑定流程(绝对准确版):
- Client 创建
Pipe_In(Client写->Server读) 和Pipe_Out(Server写->Client读)。 fork()->Baby继承了Pipe_In的两端 和Pipe_Out的两端。- 在 Baby 进程中执行:
dup2(Pipe_In_Read_End, 0)--> 绑定成功!Baby的 stdin 现在死死连着Pipe_In的读端。dup2(Pipe_Out_Write_End, 1)--> 绑定成功!Baby的 stdout 现在死死连着Pipe_Out的写端。
- 关闭多余端 :Baby 关闭它不需要的
Pipe_In_Write_End和Pipe_Out_Read_End。
- 从此,
-
此时的状态:
- Client 手里留着:
Pipe_In_Write_End和Pipe_Out_Read_End。 - Baby (未来的 Server) 手里留着:
FD 0(连着Pipe_In_Read) 和FD 1(连着Pipe_Out_Write)。 - 绑定完成 :这是一条闭环。Client 的写端 -> 管道 -> Baby 的读端。
- Client 手里留着:
身份的确认:exec() (换皮不换芯)
最后,Baby 执行 exec("java", "Server.jar")。
- 关键点 :
exec会替换代码段、堆栈、变量,但绝不会修改已经打开的文件描述符表 (除非设置了FD_CLOEXEC标志,但 Java 默认不设置)。 - 结果 :新的
Server进程醒来,它的FD 0依然指向刚才绑定的那个管道。 - 最终绑定:Client (PID 100) <--> 管道 <--> Server (PID 101)。
真正的绑定者:pipe() 系统调用
在 fork 之前,Client 进程调用了 pipe(int fd[2])。
就是这个函数,完成了 Client 和 Server 未来的连接绑定。
pipe() 到底做了什么?
当 Client 调用 pipe(fd) 时,操作系统内核做了三件事:
-
创建一个内核对象 :
内核在内存里分配了一个结构体(我们叫它
Pipe_Struct)。这个结构体里有一个缓冲区(Buffer),用来暂存数据。- 这就是未来的"通道"。
-
生成两个"钥匙"(文件描述符) :
内核创建了两个特殊的文件描述符,它们都指向同一个
Pipe_Struct,但权限不同:fd[0](读端):标记为 READ-ONLY 。指向Pipe_Struct的读接口。fd[1](写端):标记为 WRITE-ONLY 。指向Pipe_Struct的写接口。
-
交付给调用者(Client) :
内核把这两个句柄(
fd[0]和fd[1])直接放进了 当前进程(Client) 的文件描述符表中。
🔥 关键点来了:
就在 pipe() 返回的那一微秒,连接已经建立了!
fd[0]和fd[1]虽然是两个不同的数字,但它们在内核底层指向同一个缓冲区对象。- 任何往
fd[1]写的数据,都会立刻进入那个缓冲区。 - 任何从
fd[0]读的数据,都是从那个缓冲区里拿的。
这就是"连接"的本质:它们共享同一个内核缓冲区对象。
分发给 Server 的过程:继承即连接
现在,Client 手里拿着这对"连体婴儿"(fd[0] 和 fd[1])。接下来怎么给 Server?
步骤 A:fork() (复制钥匙)
Client 调用 fork()。
- 内核创建子进程(未来的 Server)。
- 内核复制了 Client 的文件描述符表给子进程。
- 结果 :
- Client 有:
fd[0](读),fd[1](写)。 - Server (子进程) 也有:
fd[0](读),fd[1](写)。 - 注意 :它们指向的是同一个 内核
Pipe_Struct! - 此时连接状态 :Client 和 Server 都同时拥有读写两端。这还没完成"单向通道"的构建,但物理连接已经存在。
- Client 有:
步骤 B:dup2() + close() (剪断多余的线,确立方向)
为了形成 Client 写 -> Server 读 的单向通道,必须扔掉不需要的端。
-
在 Server 进程中:
- 执行
dup2(fd[1], 0):把写端映射到 stdin。(修正:通常是把读端映射到 stdin,即dup2(fd[0], 0),让 Server 能读 )。- 假设我们要建立 Client 写 -> Server 读 的通道(Pipe_B):
- Server 需要读 。所以 Server 执行
dup2(fd[0], 0)。
- 执行
close(fd[1]):关键! Server 关闭了写端。 - 现状 :Server 手里只剩 下
fd[0](读端)。
- 执行
-
在 Client 进程中:
- Client 保留
fd[1](写端)。 - Client 执行
close(fd[0]):关键! Client 关闭了读端。 - 现状 :Client 手里只剩 下
fd[1](写端)。
- Client 保留
🎯 最终的连接图景
经过这一番操作:
- 内核中 :只有一个
Pipe_Struct(缓冲区)。 - Client 端 :持有唯一的 写钥匙 (
fd[1])。 - Server 端 :持有唯一的 读钥匙 (
fd[0])。
这就是连接!
- 当 Client 用它的钥匙写数据 -> 数据进入
Pipe_Struct。 - 当 Server 用它的钥匙读数据 -> 数据从
Pipe_Struct取出。 - 因为世界上只有这两把钥匙能操作这个缓冲区,所以它们天然就是连在一起的。
Q: 怎么知道 Server 的
write_B(其实是读端) 和 Client 的read_B(其实是写端) 是连着的?
A: 因为它们是在 pipe() 创建时,由内核强行"焊"在同一个对象上的。
- 不是 Server 去连接 Client。
- 不是 Client 去连接 Server。
- 而是 Client 先召唤出一个"管道对象"(通过
pipe()),这个对象天生就有两头。 - Client 把其中一头(读端)通过
fork遗传给了 Server。 - Client 自己留下了另一头(写端)。
- 然后双方各自关掉对方那一头,只留自己需要的。
比喻:
想象一根水管。
pipe():工厂生产了一根水管,两头分别是 A 口和 B 口。A 和 B 天生就是通的。fork():工厂老板(Client)克隆了一个分身(Server)。老板手里拿着 A 和 B,分身手里也拿着 A 和 B(复制品,但通向同一根管子)。close():- 老板把 B 口封死,只留 A 口用来灌水。
- 分身把 A 口封死,只留 B 口用来接水。
- 结果:老板灌的水,只能流向分身的 B 口。
为什么不会连到 Client 2?
因为 Client 2 调用 pipe() 时,工厂生产的是另一根完全不同的水管 (内存地址不同)。
Client 1 的分身(Server 1)继承的是 水管 1 的口。
Client 2 的分身(Server 2)继承的是 水管 2 的口。
水管 1 和水管 2 在物理上(内核内存中)是完全隔离的,互不相通。