关闭AsynchronousSocketChannel时连接重置错误解决方法
问题根因
你碰到的连接重置、响应截断、net::ERR_CONTENT_LENGTH_MISMATCH报错,本质是对NIO异步通道的写入语义、TCP连接关闭逻辑理解有偏差,具体问题点:
AsynchronousSocketChannel的write完成回调触发,仅代表数据被拷贝到了操作系统内核的Socket发送缓冲区,绝不等于对端已经收到并确认了全部数据。此时直接调用close(),操作系统会直接发送RST包强制中断连接,发送缓冲区里还没完成传输、没收到对端ACK的数据会被直接丢弃,对端自然只能拿到不完整的响应。- 现有写入逻辑只调用一次write,但NIO通道的write方法不保证一次性把ByteBuffer里的所有数据全部写入内核缓冲区,响应体积大的时候单次写入可能只写了一部分,剩下的数据没处理就进关闭流程,必然截断。
- 之前测试的
shutdownOutput+单次read方案逻辑不完整:shutdownOutput()只是给对端发FIN包表示本端不再发数据,之后必须持续读取通道数据,直到read返回-1——这代表对端已经收完所有数据,也发了FIN关了自己那边的连接,四次挥手正常完成,才能确认所有数据已经被对端完整接收。用1字节缓冲区只read一次,大概率还没等到对端的FIN就返回,此时关连接还是会发RST。 - 加
Thread.sleep、分配超大缓冲区read的方案本质都是赌运气等数据传完,遇到网络延迟波动、中间有负载均衡/反向代理的场景,必然复现问题。
正确关闭方案
针对HTTP/1.1 Connection: close的场景,严格按照以下步骤操作即可彻底解决问题,不需要依赖任何固定时长等待:
- 循环调用write,直到承载响应的ByteBuffer全部数据写入完成(即
buffer.hasRemaining() == false),绝对不能单次write就默认所有数据写完。 - 调用
channel.shutdownOutput(),向对端发送FIN包,明确告知本端已经完成所有响应数据的发送。 - 循环调用read读取通道数据,直到read返回-1(代表对端已经接收完所有数据,并且主动关闭了它侧的连接),读到的内容直接丢弃即可,不需要处理。
- 最后调用
channel.close()回收通道资源。
修正后代码示例
首先修复原有单次写入的问题,封装保证写完全量数据的工具方法:
suspend fun AsynchronousSocketChannel.writeFullySuspending( buffer: ByteBuffer, timeout: Duration = 60.seconds, ): Int { var totalWritten = 0 while (buffer.hasRemaining()) { val written = suspendCoroutine<Int> { continuation -> this.write( buffer, timeout.inWholeSeconds, TimeUnit.SECONDS, null, object : CompletionHandler<Int, Nothing?> { override fun completed(size: Int, attachment: Nothing?) { continuation.resume(size) } override fun failed(exc: Throwable, attachment: Nothing?) { continuation.resumeWithException(exc) } } ) } if (written < 0) throw IOException("连接已断开,响应未写完") totalWritten += written } return totalWritten }
替换原有写完直接关闭的逻辑,使用标准TCP关闭流程:
// 确保所有响应数据完整写入通道 channel.writeFullySuspending(ByteBuffer.wrap(data), 30.seconds) // 关闭输出方向,发送FIN通知对端本端发送完成 channel.shutdownOutput() // 循环读取直到对端关闭连接,确认所有数据已被对端完整接收 val readBuffer = ByteBuffer.allocate(1024) while (true) { val readSize = channel.readSuspending(readBuffer.rewind(), 30.seconds) if (readSize == -1) break } // 四次挥手完成后安全关闭通道 withContext(Dispatchers.IO) { channel.close() }
补充说明
- 上述流程是TCP正常全关闭的标准逻辑,全程不会产生RST包,不管是直连浏览器还是中间经过负载均衡、反向代理,都不会出现数据截断问题,性能也比固定sleep高得多。
- 如果是
Connection: keep-alive长连接场景,不需要关闭连接,只要严格按照Content-Length或者chunked编码的长度写完响应体,就可以继续处理下一个请求,不需要走shutdown流程。 - 循环读的时候用1KB左右的小缓冲区就足够,不需要分配大内存,之前1字节缓冲区测试失败是因为只读了一次没有循环到流结束,和缓冲区大小无关。
内容的提问来源于stack exchange,提问作者Jan Vladimir Mostert
相关产品推荐
相关产品推荐

