《Java 服务端通信工程》4.1 Stub 与 Channel 生命周期

Channel 管连接,Stub 管一次调用。本节用 linkd-v3 的 9093 端口拆开 ManagedChannel 的状态机、阻塞与异步 Stub 的不可变语义,给出预热、复用以及 shutdown、shutdownNow、awaitTermination 的完整代码,并用自包含探针验证 unary 调用。

本节目标:分清 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收