news 2026/10/5 7:59:00

Netty字符串发不出?channel与ChannelGroup排查指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Netty字符串发不出?channel与ChannelGroup排查指南

先抛一个场景。你维护着一个基于Netty的服务端,客户端连接都建立成功了,channelActive里的日志也打出来了,可当你执行channel.writeAndFlush("hello")想给客户端推一条字符串消息,或者用ChannelGroup广播了一圈,客户端那边就是干巴巴地等着,一条数据都进不来。我第一次碰上这个问题,翻来覆去查服务器端口、查防火墙、甚至怀疑是不是机房网络丢包,最后才发现锅在Netty自己的Pipeline和编码器身上。这篇文章就把channel、ChannelGroup、ctx.writeAndFlush()这几个东西掰开揉碎讲清楚,重点说说字符串消息为什么发不出去、发出去为什么对方收不到,以及从现象到根因怎么一步步定位。如果你是刚接触Netty、或者正在排查线上推送消息丢失,这篇应该能帮你少走不少弯路。

1. 先把三个关键对象搞清楚:channel、ChannelGroup、ctx.writeAndFlush()

1.1 channel是"连接"的抽象,但不是你想的那个连接

很多新手会把Netty里的Channel理解成TCP连接本身,这个认知在大多数时候能用,遇到问题就卡壳了。Channel本质上是一个对底层socket连接的封装,它负责管理连接状态、读写缓冲区、以及向EventLoop提交IO任务。一个TCP连接对应一个Channel,但Channel自己并不直接干活,真正干活的是它身上挂着的Pipeline。

这里有几个状态要分清。isOpen()表示Channel这个对象创建了还没被关闭,哪怕底层的TCP连接已经断了,只要没走close流程,isOpen()可能还是true。isActive()表示连接真正处于可用状态,TCP握手完成、可以正常读写。isWritable()表示当前Channel的出站缓冲区是否还能继续写入,如果客户端消费速度跟不上,缓冲区被写满,这里就是false。排查"收不到消息"的时候,很多人第一反应是去看服务端有没有发,但往往会忽略一个前置检查:这个连接到底还活着吗?如果客户端早就异常断开而服务端没感知到,你对着一个已经死掉的Channel写数据,消息自然到不了任何地方。

1.2 ChannelGroup是群发工具,管理不到位就是坑

ChannelGroup做的事情很朴素:把一组Channel收集起来,然后对它们统一执行writeAndFlush、close之类的操作。它的常用实现是DefaultChannelGroup,构造时需要传一个EventExecutor,一般用GlobalEventExecutor.INSTANCE就够了。管理方式很简单,channelActive的时候add,channelInactive的时候remove。

但ChannelGroup不是银弹。它只负责替你批量发送,不负责帮你判断"这个channel的pipeline能不能处理我发出去的消息类型",也不负责告诉你"这次广播里哪些channel其实已经废了"。我见过不少人把channel塞进ChannelGroup之后就不管了,连接断开也不remove,最后广播的时候一个劲儿往死连接上写数据,写出去的promise一个接一个失败,但因为没人监听这些失败,服务端日志里干净得跟什么都没发生过一样。所以用ChannelGroup一定要养成配套的习惯:add和remove成对出现,广播后检查返回的ChannelGroupFuture。

1.3 ctx.writeAndFlush() 与 channel.writeAndFlush() 的遍历差异

这俩方法看着差不多,实际差别非常大,是字符串消息发不出去的经典原因之一。

Netty的Pipeline是一个双向链表,内部维护了head和tail两个哨兵节点。读事件从head往tail方向走,写事件从tail往head方向走。这里要特别注意:写事件的"从tail往head"不是绝对的,它指的是从你发起写入的位置开始,往head方向找下一个能处理写事件的handler。

channel.writeAndFlush(msg)是从这个Channel的Pipeline的tail开始发起,所以它一定会经过链路上所有的出站处理器,包括你加的各种编码器、日志处理器、自定义的outbound处理器。ctx.writeAndFlush(msg)则是从当前这个ChannelHandlerContext所在的位置开始,往head方向找,也就是说,当前ctx后面的那些出站处理器它根本不会经过。

说个具体的例子。假设你的pipeline是:head -> 入站解码器 -> 出站编码器 -> 业务handler -> tail。在业务handler里,ctx.channel().writeAndFlush()和ctx.writeAndFlush()都能让消息经过编码器,区别不大。但如果业务handler后面还有一个自定义的outbound处理器,比如统计流量、做二次封装,这时候你用ctx.writeAndFlush()就会跳过它,消息直接往前走了。反过来,如果你在出站编码器内部调用ctx.channel().writeAndFlush(),因为从tail重新发起,会再次进入这个编码器,造成递归,栈直接爆掉。

我给个对比表,你写代码的时候对着看就行。

方法发起位置经过哪些出站处理器适用场景
ctx.writeAndFlush(msg)当前handler所在位置当前位置往head方向的所有出站处理器只想把消息写出,不想被后续的处理器再做处理
channel.writeAndFlush(msg)Pipeline的tail整个链路上所有出站处理器需要消息经过所有编码和自定义流程

我个人的经验是:在业务handler里发消息,优先用ctx.writeAndFlush(),因为它的行为更可控,而且性能上不会多一次无谓的tail遍历。如果你确定要走完整条链路的出站处理,才用channel.writeAndFlush()。

2. 客户端收不到消息:按顺序排查这6个原因

2.1 服务端没接编码器,字符串根本出不了站

这是最最最常见的原因。Netty底层最终写进socket的只有ByteBuf或者FileRegion,你直接写一个String进去,Pipeline走到head节点附近,系统发现这个消息类型没法处理,会直接抛UnsupportedOperationException,类似"unsupported message type: String (expected: ByteBuf, FileRegion)"。

这个异常会让写入的ChannelFuture以失败收场。但问题在于,如果你的写入没有挂任何监听器,这个失败是静默的。更坑的是,很多服务的handler没有重写exceptionCaught,或者重写了但只打了log就完事,甚至有的直接把异常方法写成了空实现。Netty默认的exceptionCaught行为是打印日志并关闭连接,所以客户端那边看到的往往是连接被重置,服务端这边则看到一堆异常堆栈,但你要是没盯着日志看,很容易漏掉。

解决办法很简单,在服务端的pipeline里加StringEncoder:

ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8)); ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));

StringEncoder会把CharSequence编码成ByteBuf,StringDecoder把ByteBuf解码成String。注意这俩handler都是有状态的,天然线程安全,可以使用单例,但在多handler共享时要小心@Sharable注解的问题。

2.2 客户端的解码器和服务端的发送格式对不上

服务端发出去了,客户端也不代表一定能收到。客户端pipeline里必须有一个对应的解码器,把ByteBuf还原成String。如果客户端只加了一个自定义handler,收到的msg类型是ByteBuf,你在handler里直接调msg.toString(),打出来是一堆类似"UnpooledHeapByteBuf(ridx: 0, widx: 15, cap: 1024)"的东西,看起来像没收到,其实收到了但没解码。

还有一种情况是分隔符问题。比如客户端使用了LineBasedFrameDecoder或者DelimiterBasedFrameDecoder,期望每条消息以换行符\n或者\r\n结尾,但服务端发送的字符串末尾没有带换行。这时候解码器会在内部把数据暂存起来,一直等到缓冲区的数据攒够了或者遇到分隔符才算一条完整消息。你等服务端发出去"hello",客户端那边能收到"hello\n"吗?收不到,它还在傻等那个换行符。这种问题在Netty里有个学名叫粘包拆包,属于TCP流式传输的特点,你发的多条消息在网络上根本没有边界,必须靠协议层自己划界。

如果你的场景是发不带分隔符的字符串,建议改成LengthFieldBasedFrameDecoder,前面加一个长度字段,这样不会依赖分隔符,但实现复杂度会高一些。

2.3 只write没flush,消息在缓冲区里躺着等

Netty 4.x之后,write和flush是分离的。write只是把消息放进出站缓冲区,也就是ChannelOutboundBuffer,并没有真正写到socket上。flush才是把缓冲区的数据刷出去。只调write不调flush,数据可能一直积压着,客户端自然收不到。

很多人是从Netty 3.x的老代码迁移上来的,那时候write之后有自动flush或者默认行为不一样,导致这个坑在新版本特别容易踩。我的建议很简单:能用writeAndFlush就用writeAndFlush,别再分开写。如果你确实要分开,注意flush的粒度,高频写入场景可以批量write然后一次性flush,但一定要保证最终会flush一次,否则线上会出间歇性消息延迟。

顺便提一句TCP_NODELAY。如果没开启这个选项,Nagle算法会把多个小包合并发送,配合客户端的Delayed ACK,小消息可能被延迟几十毫秒甚至更久。虽然不至于完全收不到,但表现出来就是"有时候要等好几秒"。开发环境测试字符串消息,直接两端都加上:

.option(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.TCP_NODELAY, true)

2.4 连接还没激活就写数据,写入静默失败

这个坑我在写客户端的时候踩过。Bootstrap.connect()返回的ChannelFuture是连接发起的结果,不是连接建立完成的结果。如果拿到Channel之后立刻writeAndFlush,TCP三次握手可能还没完成,消息就发出去了。

这种情况下,写入可能以NotYetConnectedException或者IllegalStateException失败,而且大多数时候这些异常不会出现在你的业务代码里,因为没有promise监听。等到你回头排查,看到的就是客户端那边风平浪静,服务端什么都没收到。

正确做法有两种。一种是给connect的future挂监听:

bootstrap.connect(host, port).addListener((ChannelFuture f) -> { if (f.isSuccess()) { f.channel().writeAndFlush("hello"); } });

另一种更推荐,把初始化发送动作放在handler的channelActive回调里,这个回调触发的时机就是连接可用的时候。写客户端代码时,凡是涉及"连接成功后的首次写入",都要检查发起时机。

2.5 ChannelGroup广播时,目标channel已经被移除了

广播场景下,最经典的问题就是往一个已经关闭的channel上写数据。客户端断网、超时、主动关闭,服务端如果没有及时感知,这个channel还会留在ChannelGroup里。每次广播,它都会收到一份写入任务,然后写入失败,promise异常,但因为没人监听,服务端日志干净得像什么都没发生。

这种情况的表现很有迷惑性:一部分客户端能收到消息,另一部分收不到,而且收不到的那部分每次都不一样,因为跟你广播的时机有关。排查的时候你会发现服务端"确实在发",但为什么某些客户端收不到,你再看一眼连接状态就明白了,那些客户端早就离线了,只是ChannelGroup里还保着它们的"僵尸"连接。

修复方案两个动作缺一不可。第一,在channelInactive里remove:

@Override public void channelInactive(ChannelHandlerContext ctx) { CLIENTS.remove(ctx.channel()); }

第二,广播之后不要什么都不做,要检查ChannelGroupFuture:

ChannelGroupFuture future = CLIENTS.writeAndFlush("message"); future.addListener(f -> { if (!f.isSuccess()) { future.forEach(cf -> { if (!cf.isSuccess()) { System.out.println("发送失败 channel=" + cf.channel() + " cause=" + cf.cause()); } }); } });

2.6 事件循环线程被阻塞,写入任务排队到地老天荒

还有一个不那么明显的原因是EventLoop被长时间阻塞。EventLoop是Netty处理读写事件的核心线程,它同时服务多个channel。如果你在某个channel的handler里做了耗时操作,比如数据库查询、外部HTTP调用、Thread.sleep,那么这个EventLoop上的所有channel都会被拖慢。写入任务虽然通过ctx.writeAndFlush()提交了,但EventLoop一直忙着执行前面的阻塞任务,写入迟迟不被处理,客户端自然也就收不到消息。

这种问题的特征是:服务端看起来没报错,但整体吞吐下降,所有连接都延迟。排查方法很简单,遇到疑似情况先抓线程栈看看EventLoop线程在干什么。生产环境里的规矩是:任何耗时操作都丢给业务线程池,不要在EventLoop里做。

3. 一次真实排障实录:从"收不到"到"根因修复"

3.1 现场情况:能连上、能读,唯独不能写

我之前维护过一个在线列表的推送服务,服务端维护了一个ChannelGroup,每5秒广播一次在线人数。上线一段时间后,运营反馈有些客户端的在线人数再也不刷新了。检查客户端日志,显示连接一直活着,也没有明显的报错。服务端看监控,广播任务一直在跑,日志里也打了"广播完成"。最诡异的是,同一批客户端里,一部分能正常收到,另一部分收不到,而且收不到的那批是逐渐变多的。

第一反应是网络问题,但抓包之后发现,服务端和客户端之间的TCP连接在某些客户端上根本没有活跃的数据包。这就奇怪了,客户端明明显示连接正常。

3.2 完整排查链路:抓包、看Pipeline、看日志

我先把服务端广播代码从头到尾看了一遍,发现问题出在ChannelGroup的管理上。服务端的channelInactive没有被重写,也就是说连接断开后,没有任何代码把channel从ChannelGroup里remove出去。那些"收不到"的客户端,客户端进程早就因为网络波动、移动端切后台等原因断开了,但服务端不知道。

为什么服务端不知道?TCP连接异常断开时,服务端要依赖TCP的KeepAlive或者自己实现的心跳来感知。我们当时只做了业务层面的5秒广播,没有做双向心跳,客户端异常断电后,服务端要等很久才能通过一次写入失败感知到连接已死。而在这个感知发生之前,ChannelGroup里全是这种僵尸连接。

这时候再看广播逻辑:每次广播,CLIENTS.writeAndFlush()会对所有channel发起写入。对已关闭的channel,写入会以ClosedChannelException失败。但我们的广播代码只调了writeAndFlush,没有挂监听器,这个失败在网上悄无声息地消失了。服务端的日志当然干净。

3.3 根因与修复:promise失败为什么没人知道

根因一句话总结:不是消息没发出去,而是发给了已经死了的连接,且没人检查发送结果。修复方案有三步。

第一步,在channelInactive里remove连接。第二步,广播后遍历ChannelGroupFuture排查失败项,至少把失败原因打印出来。第三步,加上心跳机制,让服务端能及时感知死连接,而不是靠广播的时候撞运气。

这个案例给我最大的教训是:writeAndFlush的返回值是有意义的。Netty里几乎所有IO操作都是异步的,返回值是一个Future,它承载了这次操作成功还是失败的最终结果。你不对它负责,它就默认帮你把错误吞掉,然后让你的程序在一种"看似正常但实际已经出问题"的状态里跑很久。后来我们团队定了个规矩:线上代码里所有writeAndFlush返回的future必须挂至少一个监听器,哪怕是打一行日志。

4. 可直接抄作业的字符串收发方案

4.1 服务端完整代码:单发+群发

直接给一份能跑起来的服务端代码,pipeline、编码器、ChannelGroup管理都放在一起,你照着改端口和业务逻辑就能用。

public class StringServer { private static final ChannelGroup CLIENTS = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public static void main(String[] args) throws Exception { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(boss, worker) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8)); ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8)); ch.pipeline().addLast(new ServerHandler()); } }); ChannelFuture bindFuture = bootstrap.bind(8080).sync(); System.out.println("server started on 8080"); bindFuture.channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }

ServerHandler负责连接管理和消息处理。注意在channelActive里add,channelInactive里remove,改header异常处理一定不要空实现。

public class ServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelActive(ChannelHandlerContext ctx) { CLIENTS.add(ctx.channel()); System.out.println("online: " + ctx.channel().remoteAddress()); // 单发:用ctx写,只走当前handler之前的出站处理器 ctx.writeAndFlush("welcome\n") .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); } @Override public void channelInactive(ChannelHandlerContext ctx) { CLIENTS.remove(ctx.channel()); System.out.println("offline: " + ctx.channel().remoteAddress()); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { String text = (String) msg; System.out.println("recv from " + ctx.channel().remoteAddress() + ": " + text); // 群发:给所有在线连接广播 ChannelGroupFuture broadcastFuture = CLIENTS.writeAndFlush("[broadcast] " + text + "\n"); broadcastFuture.addListener(f -> { if (!f.isSuccess()) { broadcastFuture.forEach(cf -> { if (!cf.isSuccess()) { System.err.println("broadcast fail: " + cf.channel() + " -> " + cf.cause()); } }); } }); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } }

4.2 客户端完整代码:解码与输出

客户端同样要配好解码器和编码器。收到消息后,因为pipeline里有StringDecoder,channelRead0里拿到的msg直接就是String,不用再手动转ByteBuf。

public class StringClient { public static void main(String[] args) throws Exception { EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap bootstrap = new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8)); ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8)); ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() { @Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println("recv: " + msg); } }); } }); Channel channel = bootstrap.connect("127.0.0.1", 8080).sync().channel(); channel.writeAndFlush("hello server\n") .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); channel.closeFuture().sync(); } finally { group.shutdownGracefully(); } } }

4.3 生产环境的几个加固点

上面的代码能跑通本地测试,但放到生产环境还需要注意几个点。

不要在main线程里用while循环发送广播,应该用EventLoop的schedule方法。这样能保证广播任务和IO事件在同一个线程里串行执行,避免多线程同时操作Channel的竞态问题。

// 在某个handler的channelActive里启动定时任务 ctx.executor().scheduleAtFixedRate(() -> { CLIENTS.writeAndFlush("heartbeat\n") .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); }, 5, 5, TimeUnit.SECONDS);

如果你要发大量消息,先用channel.isWritable()做背压判断,否则客户端消费不过来,缓冲区会越积越大,最后内存出问题。开发阶段可以在pipeline最前面加一个LoggingHandler,能直接看到每条读写消息的类型和内容,这个工具在排查"到底发没发出去"的时候特别好使。

5. 常见问题速查表与我的排错习惯

5.1 症状、原因、处理对照速查

症状常见原因处理方式
服务端日志出现unsupported message type: Stringpipeline缺StringEncoder加上StringEncoder(UTF_8)
客户端handler收到的是ByteBuf客户端缺StringDecoder加上StringDecoder
客户端用LineBasedFrameDecoder但一直没消息发送的字符串不带换行符消息加\n,或换长度字段协议
客户端很久才收到一次消息未开启TCP_NODELAY,或只write没flush两端开TCP_NODELAY,统一writeAndFlush
connect后立刻write抛IllegalStateExceptionchannel还没注册到EventLoop在channelActive里发,或给connect挂监听
部分客户端收不到广播,无任何报错ChannelGroup里有已关闭连接,且没监听FuturechannelInactive里remove,检查ChannelGroupFuture
客户端收到乱码服务端和客户端字符集不一致统一使用UTF_8
所有连接延迟严重,服务端线程看着卡住EventLoop被耗时操作阻塞耗时操作丢业务线程池
写入时出现ClosedChannelException往已关闭的连接写数据写前判isActive,写后监听Future

5.2 三个踩坑后养成的编码习惯

先说检查Future。所有writeAndFlush调用,线上代码一律挂监听器。最简单粗暴的写法是addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE),它不仅帮你打印异常,还会把异常重新抛给这个Channel的exceptionCaught链路,这样至少不会静默丢失。我见过太多线上事故,根源都是"那个写失败的异常没人看"。

再说画Pipeline。我每次写Netty的handler之前,都会先在纸上或者注释里画出Pipeline的结构,标清楚哪个是inbound、哪个是outbound,然后问自己一个问题:我这条消息从发起到真正写进socket,会经过哪些handler?尤其是用ctx.writeAndFlush()还是channel.writeAndFlush(),画完图之后一眼就能确定。别嫌麻烦,这个动作能避免一半以上的消息丢失问题。

最后说异常处理。exceptionCaught不要写空实现,不要只打一行debug日志。哪怕你暂时不知道怎么处理,至少要把堆栈打印出来。很多类似的"消息收不到"问题,其实服务端早就在exceptionCaught里暴露过根因,只是没人看。等到线上出问题再回来翻日志,那感觉是真的酸爽。

顺便再提一个我自己现在写代码的习惯:开发环境必加LoggingHandler,测试完再摘掉。Netty的LoggingHandler会打印每条入站、出站消息的类名、字节长度和内容摘要,排查"客户端收不到"的时候,它能直接告诉你消息到底有没有走到对应的节点。有一次我花了一下午定位一个广播问题,最后发现是有人在handler里做了消息体拦截,直接把字符串转成了别的东西。有了LoggingHandler,这种事情基本十分钟就能定位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 7:58:21

手写Vue 3.4响应式系统:深入ref与computed核心实现

【手写 vue3.4 响应式系统】实现 ref 和 computed好久没碰源码了&#xff0c;最近在 uni-app 里遇到一个和 ref 有关的怪问题——明明把响应式变量传给了子组件&#xff0c;子组件也正常渲染了&#xff0c;但一改值&#xff0c;页面就是纹丝不动。折腾一晚上&#xff0c;最后干…

作者头像 李华
网站建设 2026/10/5 7:58:02

Airflow、Luigi与Oozie对比:工作流编排框架选型实战指南

在数据工程这个圈子里混得久了&#xff0c;你会发现一个很有意思的现象&#xff1a;几乎每个团队最终都会遇到同一个问题——任务多了&#xff0c;依赖乱了&#xff0c;调度靠crontab硬撑的一段"黑暗时期"过去了&#xff0c;下一步就是选型一个工作流编排框架。Airfl…

作者头像 李华
网站建设 2026/10/5 7:57:47

附加惯性项BP神经网络让四旋翼姿态控制更稳更准

简介&#xff1a;针对传统PID控制参数无法在线整定、控制精度不高的问题&#xff0c;该研究提出将附加惯性项BP神经网络与PID控制相结合的四旋翼无人机姿态控制方法&#xff0c;从无人机自身特性出发&#xff0c;通过惯性系数修正和网络参数自整定来提升受扰飞行下的姿态稳定性…

作者头像 李华
网站建设 2026/10/5 7:57:41

五伏信号怎样安全送进三点三伏输入?看懂两只电阻分压

五伏信号怎样安全送进三点三伏输入&#xff1f;看懂两只电阻分压本文为教学接线与设计预期&#xff0c;尚无实物测量记录&#xff1b;使用 NUCLEO-F030R8。问题 五伏开发板输出只有高和低&#xff0c;能直接接三点三伏单片机吗&#xff1f;不能只看它是否读到一。这里选不能当五…

作者头像 李华
网站建设 2026/10/5 7:57:05

Python asyncio实战:事件循环、协程与高并发爬虫全解析

其实在很多项目里&#xff0c;真正需要一个“高性能并发方案”的时候&#xff0c;你翻开Python的官方文档&#xff0c;十有八九会被引导到 asyncio 这个库。我最早接触它是因为要写一个批量爬虫&#xff0c;几百上千个URL要并发抓取&#xff0c;用线程池写起来倒也不难&#…

作者头像 李华
网站建设 2026/10/5 7:55:26

网址转App零代码实操:从WebView原理到打包上架全攻略

先说我自己的一个例子。去年帮朋友做的一个小工具站&#xff0c;数据都在浏览器里跑得好好的&#xff0c;他非要一个App&#xff0c;理由很简单——“用户就是觉得装个App更靠谱&#xff0c;放浏览器里像个临时页面”。我当时的内心活动是&#xff1a;就一个展示页&#xff0c;…

作者头像 李华