本节目标:分清 Channel 与 Stub 各自的生命周期,知道连接什么时候建立、什么时候复用、什么时候关闭,并能写出不会泄漏线程和 fd 的
LibraryClient。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+ Protobuf 4.33.2。
承接的问题
3.3 把幂等键和重试策略讲完之后,linkd-v3 已经能在一次调用里判断该不该重试。但重试代码要拿到一个可用的 stub,问题立刻变成对象生命周期:每次调用都 new 一个 channel,还是全进程共用一个?调完就 shutdown(),还是交给进程退出时统一收?阻塞 stub 和异步 stub 能不能共享同一条连接?
这些问题在单元测试里不明显,因为测试通常只发一两个请求。一上生产就会暴露:按请求建 channel 会把 TIME_WAIT、文件描述符和 TLS 握手次数一起推高;忘了关闭 channel 会让 Netty 的 EventLoop 线程一直活着,进程收到 SIGTERM 后不退出;在请求线程里调用 awaitTermination 则可能把整个线程池拖住。
这一节先把两个对象的职责分开,再讲连接状态机、预热和三种关闭力度,最后把 LibraryClient 放进 linkd-v3 的启动/停止流程。第 6 章会在这个基础上讨论什么时候需要多条 channel,本节只回答「一条 channel 应该怎么活」。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。文中的状态名和 API 都按 1.76.0 核对过。
核心机制一:Channel 是连接管理器,Stub 是调用视图
ManagedChannel 不是一个 TCP 连接,而是一套连接管理器:它持有名字解析器、负载均衡策略、子通道(subchannel)、执行器和取消传播逻辑。真正的 TCP 连接在 subchannel 里,可能有一条,也可能有多条;对调用方来说,这些都藏在 Channel 接口后面。
LibraryGrpc.newBlockingStub(channel) 返回的 stub 只是「channel + 一组 CallOptions」的轻量视图。它不持有连接,也不启动线程;每次调用时把方法描述符、请求消息和选项交给 channel。stub 是不可变的:withDeadlineAfter、withCompression、withInterceptors 都返回新 stub,原来的 stub 不受影响。因此基础 stub 可以安全地跨线程共享。
创建 channel 的推荐写法是 NettyChannelBuilder.forAddress(host, port, InsecureChannelCredentials.create())。forAddress(String, int) 加 usePlaintext() 仍然能用,但 usePlaintext() 已标记为弃用,新代码统一传凭证对象;需要走名字解析时用 forTarget(target, creds),5.2 会展开。
package com.plume.linkd.v3.core.client;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import java.util.concurrent.TimeUnit;
public final class LibraryClient implements AutoCloseable {
private final ManagedChannel channel;
private final LibraryGrpc.LibraryBlockingStub blockingStub;
private final LibraryGrpc.LibraryStub asyncStub;
public LibraryClient(String host, int port) {
this.channel = NettyChannelBuilder
.forAddress(host, port, InsecureChannelCredentials.create())
.defaultLoadBalancingPolicy("round_robin")
.maxInboundMessageSize(4 * 1024 * 1024)
.build();
this.blockingStub = LibraryGrpc.newBlockingStub(channel);
this.asyncStub = LibraryGrpc.newStub(channel);
}
public Book getBook(String isbn) {
return blockingStub
.withDeadlineAfter(300, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
}
public LibraryGrpc.LibraryStub async() {
return asyncStub;
}
@Override
public void close() {
channel.shutdown();
try {
if (!channel.awaitTermination(5, TimeUnit.SECONDS)) {
channel.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
channel.shutdownNow();
}
}
}
排版说明:shaded 的包名较长,代码里的 import 在点号前折行;Java 允许在限定名的点号前后换行,等价于单行 import。
三个细节:
build()不建连接。 默认情况下 channel 处于IDLE,第一次 RPC 才触发名字解析和 TCP 连接。需要提前建连时调getState(true)或先发一个轻量探针,见下一节。- stub 只存引用。
blockingStub和asyncStub共享同一条 channel,底层复用同一个连接池和 EventLoop;不要为两种 stub 各建一条 channel。 close()是进程级动作。 它假设没有新的调用会进来;如果还有并发调用,先走 6.3 的 drain 流程,再关闭 channel。
核心机制二:连接状态机与预热
ManagedChannel 暴露的状态是 ConnectivityState:
| 状态 | 含义 | 触发下一步 |
|---|---|---|
IDLE | 尚未解析或连接,按需启动 | 第一次 RPC、getState(true) |
CONNECTING | 名字解析完成,正在建连 | 成功进 READY,失败进 TRANSIENT_FAILURE |
READY | 至少一个子通道可用 | 可发 RPC |
TRANSIENT_FAILURE | 建连失败,按退避重试 | 退避后回 CONNECTING |
SHUTDOWN | 已关闭,不再接受调用 | 终态 |
getState(boolean requestConnection) 的布尔参数表示「如果当前是 IDLE,是否顺带触发建连」。传 true 适合启动预热;传 false 只观察,不改变状态。notifyWhenStateChanged(oldState, callback) 在状态从 oldState 变化时执行回调,回调可能在不同的线程上运行,不能在里面做阻塞操作。
预热的价值是把 DNS、TCP 和 TLS 的延迟从第一个真实请求里挪走。下面这个等待器用 CountDownLatch 把异步回调转成有界等待:
package com.plume.linkd.v3.core.client;
import io.grpc.ConnectivityState;
import io.grpc.ManagedChannel;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public final class ChannelWarmup {
public static boolean awaitReady(ManagedChannel channel, long timeoutMillis)
throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
while (true) {
ConnectivityState state = channel.getState(true);
if (state == ConnectivityState.READY) {
return true;
}
if (state == ConnectivityState.SHUTDOWN) {
return false;
}
long remaining = deadline - System.nanoTime();
if (remaining <= 0L) {
return false;
}
CountDownLatch changed = new CountDownLatch(1);
channel.notifyWhenStateChanged(state, changed::countDown);
changed.await(remaining, TimeUnit.NANOSECONDS);
}
}
}
notifyWhenStateChanged 只保证「状态不再是传入的那个值」时通知一次,不保证一定是 READY;所以每次醒来都要重新读状态。循环里没有 Thread.sleep,等待由 latch 承担。awaitReady 返回 false 时不要直接抛异常,调用方可以决定是降级启动还是继续等待。
TRANSIENT_FAILURE 的退避由 gRPC 管理,初始约 1 秒并指数增长,上限约 120 秒。测试环境里频繁重启服务端时,可以调 channel.resetConnectBackoff() 立即重试;生产代码不要在每个请求失败时都调它,否则会把退避保护打掉。
核心机制三:三种关闭力度
ManagedChannel 的关闭有三个层次,语义完全不同:
| 方法 | 新调用 | 在途调用 | 何时用 |
|---|---|---|---|
shutdown() | 拒绝,立即失败 | 允许跑完 | 正常停机 |
shutdownNow() | 拒绝 | 取消 | 超时兜底、故障进程 |
awaitTermination(timeout) | 不改变 | 等待结束 | 配合前两者 |
shutdown() 之后 channel 进入 SHUTDOWN,新的 RPC 会以 UNAVAILABLE 失败,错误信息里通常带 “Channel shutdownNow invoked” 或 “Channel shutdown invoked”。已经发出的调用继续走完,直到服务端返回或 deadline 到期。shutdownNow() 会取消所有在途调用,客户端观察到 CANCELLED。awaitTermination 只等待,不触发关闭;它返回 true 表示 channel 已经终止。
正确的顺序是先 shutdown(),再有界等待,最后 shutdownNow() 兜底。上面的 LibraryClient.close() 就是这个顺序。两个常见变体:
// 变体一:停机预算很短,直接取消
channel.shutdownNow();
channel.awaitTermination(1, TimeUnit.SECONDS);
// 变体二:先停止新调用,等在途请求自己结束
channel.shutdown();
if (!channel.awaitTermination(5, TimeUnit.SECONDS)) {
channel.shutdownNow();
channel.awaitTermination(1, TimeUnit.SECONDS);
}
shutdown() 和 shutdownNow() 都是幂等的,重复调用不会抛异常;awaitTermination 可以被中断,捕获 InterruptedException 后要恢复中断标记,再决定是否 shutdownNow()。不要在 gRPC 的回调线程或 Netty 的 EventLoop 上调用 awaitTermination,那会把该线程负责的其他调用一起卡住。
在 linkd-v3 里落地:一个进程一条 Channel
linkd-v3 的 gRPC 客户端只有一个目标:libraryd 的 9093。网关 9094 和后台任务共享同一个 LibraryClient 实例,实例由启动类创建,随进程关闭。这样做的原因不是「省资源」这么简单:
- 连接复用。 HTTP/2 一条连接可以承载多个并发流,按请求建 channel 等于放弃多路复用。
- 负载均衡状态。 channel 里的 subchannel 和健康状态需要时间积累;每次新建都从
IDLE开始,round_robin 还没轮转就被关掉。 - 可观测性。 连接数、重连次数、在途流数量都挂在 channel 上,分散创建会让指标失去意义。
启动流程可以写成:
LibraryClient library = new LibraryClient("libraryd.internal", 9093);
if (!ChannelWarmup.awaitReady(library.channel(), 1_000)) {
LOG.log(System.Logger.Level.WARNING, "libraryd 未在 1s 内就绪,降级启动");
}
Runtime.getRuntime().addShutdownHook(new Thread(library::close, "linkd-grpc-close"));
LibraryClient 目前把 channel 设为私有;需要预热时给它加一个包内可见的 ManagedChannel channel() 访问器,或者把 awaitReady 放进 LibraryClient 自己。不要让业务代码直接持有 channel 去 shutdown(),否则其他线程还在用的连接会被一起关掉。
为了确认这套 API 在当前依赖上真的能跑,下面是一个自包含探针:服务端用 NettyServerBuilder.forPort(0) 拿随机端口,注册一个 ServerServiceDefinition,客户端用 ClientCalls.blockingUnaryCall 发一个 unary 请求。它不依赖生成的 proto 类,只用一个 UTF-8 marshaller 验证调用链。
package com.plume.linkd.v3.probe;
import io.grpc.CallOptions;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.MethodDescriptor;
import io.grpc.Server;
import io.grpc.ServerServiceDefinition;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.stub.ClientCalls;
import io.grpc.stub.ServerCalls;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
public final class UnaryProbe {
static final MethodDescriptor.Marshaller<String> UTF8 = new MethodDescriptor.Marshaller<>() {
@Override
public InputStream stream(String value) {
return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8));
}
@Override
public String parse(InputStream stream) {
try {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (java.io.IOException e) {
throw new IllegalStateException("read utf8", e);
}
}
};
public static void main(String[] args) throws Exception {
MethodDescriptor<String, String> method = MethodDescriptor.<String, String>newBuilder()
.setType(MethodDescriptor.MethodType.UNARY)
.setFullMethodName("plume.linkd.v3.Library/GetBook")
.setRequestMarshaller(UTF8)
.setResponseMarshaller(UTF8)
.build();
Server server = NettyServerBuilder.forPort(0)
.addService(ServerServiceDefinition.builder("plume.linkd.v3.Library")
.addMethod(method, ServerCalls.asyncUnaryCall((request, observer) -> {
observer.onNext("book:42");
observer.onCompleted();
}))
.build())
.build()
.start();
ManagedChannel channel = NettyChannelBuilder
.forAddress("127.0.0.1", server.getPort(), InsecureChannelCredentials.create())
.build();
try {
String reply = ClientCalls.blockingUnaryCall(channel, method,
CallOptions.DEFAULT.withDeadlineAfter(800, TimeUnit.MILLISECONDS), "42");
System.out.println("unary=" + reply);
} finally {
channel.shutdownNow().awaitTermination(1, TimeUnit.SECONDS);
server.shutdownNow().awaitTermination(1, TimeUnit.SECONDS);
}
}
}
本机输出:
unary=book:42
同一进程还会打印 JDK 25 的终结弃用警告,来源是 shaded Netty 内部的 sun.misc.Unsafe::allocateMemory,不是探针代码的问题:
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by io.grpc.netty.shaded.io.netty.util.internal.PlatformDependent0$2
WARNING: Please consider reporting this to the maintainers of class io.grpc.netty.shaded.io.netty.util.internal.PlatformDependent0$2
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
这段输出说明两件事:gRPC 1.76.0 在 JDK 25 上可用;依赖内部仍在使用 JDK 后续版本会移除的 API。升级 JDK 时要重新跑一遍,不能假设「上次能跑这次也能跑」。
常见错误与边界
错误一:按请求创建 channel。
// 反例:每个请求一条 channel
for (String isbn : isbns) {
try (ManagedChannel ch = NettyChannelBuilder
.forAddress("libraryd.internal", 9093, InsecureChannelCredentials.create())
.build()) {
LibraryGrpc.newBlockingStub(ch).getBook(request(isbn));
}
}
每次都会新建 EventLoop、解析地址、TCP 握手,HTTP/2 的多路复用完全没用上;高 QPS 下端口和 fd 先耗尽。channel 应该是长生命周期对象,stub 才是每次调用新建的轻量对象。
错误二:在 EventLoop 或回调线程里调用阻塞 stub。
阻塞 stub 会等响应,等在 gRPC 的传输线程上会把同一条连接上的其他流一起卡住。网关的 handler 里要么用异步 stub,要么把阻塞调用提交到业务线程池;第 3 章的线程模型在这里同样适用。
错误三:shutdown() 后继续用旧 stub。
stub 不感知 channel 已关闭,调用会在 channel 层失败,状态通常是 UNAVAILABLE。关闭顺序必须是「先停止入口流量,再关 channel」,不能反过来指望调用方自己发现。
错误四:只调 shutdownNow() 不做 drain。
shutdownNow() 会取消在途调用,正在写库的请求可能只完成一半。正常发布要走 shutdown() 加超时等待,shutdownNow() 只作为最后兜底。
边界:channel 不是越多越好。 一条 channel 的并发上限受 HTTP/2 的 MAX_CONCURRENT_STREAMS、流控窗口和服务端处理能力共同约束。确实需要多条时,理由应该是隔离或并发上限,而不是「多建几条会更快」;第 6 章会给出判定方法。
边界:usePlaintext() 不是「关闭加密」的开关。 它表示这条连接不启用 TLS。生产环境的 9093 如果走 mTLS,要用 TlsChannelCredentials,不能为了省事传 InsecureChannelCredentials。
小结
ManagedChannel管理名字解析、负载均衡、子通道和执行器;stub 只是 channel 加一组调用选项的不可变视图。- 推荐用
forAddress(host, port, InsecureChannelCredentials.create())建 channel;usePlaintext()已弃用。 - 连接状态有
IDLE、CONNECTING、READY、TRANSIENT_FAILURE、SHUTDOWN,getState(true)可触发建连,notifyWhenStateChanged用于有界等待。 - 关闭顺序是
shutdown()、有界awaitTermination、必要时shutdownNow();三个方法都幂等,但语义不同。 - 一个进程到同一个目标通常只需要一条 channel,按请求建 channel 会放弃多路复用并耗尽 fd。
- 自包含探针验证了 1.76.0 的 unary 调用,输出
unary=book:42;JDK 25 的Unsafe警告来自 shaded Netty,不要伪造成「无警告」。
下一节处理横切关注点:请求 ID、租户、traceparent 这些元数据怎么在客户端注入、在服务端读取并回显,以及拦截器里 Context 的附着与清理。
阅读导航:上一节 3.3 幂等、重试与请求去重 | 下一节 4.2 Interceptor 与 Metadata
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。