我为 Netty 贡献源码 |(二)

justtouch
发布于 2022-8-9 17:24
浏览
0收藏

2.1.5 Channel 的关闭
我为 Netty 贡献源码 |(二)-鸿蒙开发者社区我为 Netty 贡献源码 |(二)-鸿蒙开发者社区

prepareToClose 方法返回的 closeExecutor 是用来执行 Channel 关闭操作的,当我们开启了 SO_LINGER 选项时,closeExecutor = GlobalEventExecutor.INSTANCE ,避免了 Reactor 线程的阻塞。

由 GlobalEventExecutor 负责执行 doClose0 方法关闭 Channel 底层的 Socket,并通知 closeFuture 关闭结果。

       private void close(final ChannelPromise promise, final Throwable cause,
                           final ClosedChannelException closeCause, final boolean notify) {
            
            ...........省略重进入关闭流程处理........

            ...........省略Channel关闭前的准备工作........

            Executor closeExecutor = prepareToClose();
            if (closeExecutor != null) {
                closeExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // 在GlobalEventExecutor中执行channel的关闭任务,设置closeFuture,promise success
                            doClose0(promise);
                        } finally {
                            // reactor线程中执行
                            invokeLater(new Runnable() {
                                @Override
                                public void run() {
                                    if (outboundBuffer != null) {
                                        // cause = closeCause = ClosedChannelException, notify = false
                                        // 此时channel已经关闭,需要清理对应channelOutboundBuffer中的待发送数据flushedEntry
                                        outboundBuffer.failFlushed(cause, notify);
                                        //循环清理channelOutboundBuffer中的unflushedEntry
                                        outboundBuffer.close(closeCause);
                                    }
                                    //这里的active = true
                                    //关闭channel后,会将channel从reactor中注销,首先触发ChannelInactive事件,然后触发ChannelUnregistered
                                    fireChannelInactiveAndDeregister(wasActive);
                                }
                            });
                        }
                    }
                });
            } else {
                 ...........省略在Reactor中Channel关闭的逻辑........
            }
        }

当 Channel 的关闭操作在 closeExecutor 线程中执行完毕之后,此时 Channel 从物理上就已经关闭了,但是 Channel 中还有一些遗留的东西需要清理,比如 Channel 对应的写入缓冲队列 ChannelOutboundBuffer 中的待发送数据需要被清理掉,并通知用户线程由于 Channel 已经关闭,导致数据发送失败。

同时 Netty 也需要让用户感知到 Channel 已经关闭的事件,所以还需要在关闭 Channel 对应的 pipeline 中触发 ChannelInactive 事件和 ChannelUnregistered 事件。

而以上列举的这两点清理 Channel 的相关工作则需要在 Reactor 线程中完成,不能在 closeExecutor 线程中完成。这是处于线程安全的考虑,因为在 Channel 关闭之前,对于 ChannelOutboundBuffer 以及 pipeline 的操作均是由 Reactor 线程来执行的,Channel 关闭之后相关的清理工作理应继续由 Reactor 线程负责,避免多线程执行产生线程安全问题。

2.1.5.1 doClose0 关闭 Channel

        // 关闭channel操作的指定future,来判断关闭流程进度 每个channel一个
        private final CloseFuture closeFuture = new CloseFuture(this);

        private void doClose0(ChannelPromise promise) {
            try {
                // 关闭channel,此时服务端向客户端发送fin2,服务端进入last_ack状态,客户端收到fin2进入time_wait状态
                doClose();
                // 设置clostFuture的状态为success,表示channel已经关闭
                // 调用shutdownOutput则不会通知closeFuture
                closeFuture.setClosed();
                // 通知用户promise success,关闭操作已经完成
                safeSetSuccess(promise);
            } catch (Throwable t) {
                closeFuture.setClosed();
                // 通知用户线程关闭失败
                safeSetFailure(promise, t);
            }
        }

首先调用 doClose() 方法关闭底层 JDK 中的 SocketChannel 。

public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {

    @Override
    protected void doClose() throws Exception {
        super.doClose();
        javaChannel().close();
    }

}

这里大家需要注意的一个点是,在 JDK 底层 SocketChannel 的关闭方法中,同样也会将该 Channel 关联的所有 SelectionKey 取消掉。因为在 prepareToClose 方法中我们提到,只有我们设置了 SO_LINGER 选项时,才会在 prepareToClose 方法中调用 doDeregister 方法将 Channel 关联的 SelectionKey 从 Selector 中取消掉。

而当我们没有设置 SO_LINGER 选项时,则不会提前调用 doDeregister 方法取消。所以需要在这里真正关闭 Channel 的地方,将其关联的所有 SelectionKey 取消掉。

    public final void close() throws IOException {
        synchronized (closeLock) {
            if (!open)
                return;
            open = false;
            implCloseChannel();
        }
    }

    protected final void implCloseChannel() throws IOException {
        implCloseSelectableChannel();
        synchronized (keyLock) {
            int count = (keys == null) ? 0 : keys.length;
            //关闭与该Channel相关的所有SelectionKey
            for (int i = 0; i < count; i++) {
                SelectionKey k = keys[i];
                if (k != null)
                    k.cancel();
            }
        }
    }


当我们调用了 doClose() 方法后,此时服务端的内核协议栈就会向客户端发出 FIN 包,服务端结束 CLOSE_WAIT 状态进入 LAST_ACK 状态。客户端收到服务端的 FIN 包后,向服务端回复 ACK 包,随后客户端进入 TIME_WAIT 状态。服务端收到客户端的 ACK 包后结束 LAST_ACK 状态进入 CLOSE 状态。

我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
当调用 doClose() 完成 Channel 的关闭后,就会调用 closeFuture.setClosed() 通知 Channel 的 closeFuture 关闭成功。

static final class CloseFuture extends DefaultChannelPromise {

        boolean setClosed() {
            return super.trySuccess();
        }

}

随后调用 safeSetSuccess(promise) 通知用户的 promise 关闭成功。

我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
2.1.5.2 清理 ChannelOutboundBuffer

这里大家需要注意:清空 ChannelOutboundBuffer 的操作是在 Reactor 线程中执行的。

我为 Netty 贡献源码 |(二)-鸿蒙开发者社区

       if (outboundBuffer != null) {
                // Fail all the queued messages
                // cause = closeCause = ClosedChannelException, notify = false
                // 此时channel已经关闭,需要清理对应channelOutboundBuffer中的待发送数据flushedEntry
                outboundBuffer.failFlushed(cause, notify);
                //循环清理channelOutboundBuffer中的unflushedEntry
                outboundBuffer.close(closeCause);
       }

当 Channel 关闭之后,此时 Channel 中的写入缓冲队列 ChannelOutboundBuffer 中可能会有一些待发送数据,这时就需要将这些待发送数据从 ChannelOutboundBuffer 中清除掉。

通过调用 ChannelOutboundBuffer#failFlushed 方法,循环遍历 flushedEntry 指针到 tailEntry 指针之间的 Entry 对象,将其从 ChannelOutboundBuffer 链表中删除,并释放 Entry 对象中封装的 byteBuffer ,通知用户的 promise 写入失败。并回收 Entry 对象实例。

public final class ChannelOutboundBuffer {

    void failFlushed(Throwable cause, boolean notify) {
        if (inFail) {
            return;
        }

        try {
            inFail = true;
            for (;;) {
                // 循环清除channelOutboundBuffer中的待发送数据
                // 将entry从buffer中删除,并释放entry中的bytebuffer,通知promise failed
                if (!remove0(cause, notify)) {
                    break;
                }
            }
        } finally {
            inFail = false;
        }
    }

    private boolean remove0(Throwable cause, boolean notifyWritability) {
        Entry e = flushedEntry;
        if (e == null) {
            //清空当前reactor线程缓存的所有待发送数据
            clearNioBuffers();
            return false;
        }
        Object msg = e.msg;

        ChannelPromise promise = e.promise;
        int size = e.pendingSize;
        //从channelOutboundBuffer中删除该Entry节点
        removeEntry(e);

        if (!e.cancelled) {
            // only release message, fail and decrement if it was not canceled before.
            //释放msg所占用的内存空间
            ReferenceCountUtil.safeRelease(msg);
            //编辑promise发送失败,并通知相应的Lisener
            safeFail(promise, cause);
            //由于msg得到释放,所以需要降低channelOutboundBuffer中的内存占用水位线,并根据notifyWritability决定是否触发ChannelWritabilityChanged事件
            decrementPendingOutboundBytes(size, false, notifyWritability);
        }

        // recycle the entry
        //回收Entry实例对象
        e.recycle();

        return true;
    }
}

在 remove0 方法中 netty 会将已经关闭的 Channel 对应的 ChannelOutboundBuffer 中还没来得及 flush 进 Socket 发送缓存区中的数据全部清除掉。这部分数据就是上图中 flushedEntry 指针到 tailEntry 指针之间的 Entry对象。

Entry 对象中封装了用户待发送数据的 ByteBuffer,以及用于通知用户发送结果的 promise 实例。

这里需要将这些还未来得及 flush 的 Entry 节点从 ChannelOutboundBuffer 中全部清除,并释放这些 Entry 节点中包裹的发送数据 msg 所占用的内存空间。并标记对应的 promise 为失败同时通知对应的用户 listener 。

以上的清理逻辑主要是应对在 Channel 即将关闭之前,用户极限调用 flush 操作想要发送数据的情况。

另外还有一种情况 Netty 这里需要考虑处理,由于在关闭 Channel 之前,用户可能还会向 ChannelOutboundBuffer 中 write 数据,但还未来得及调用 flush 操作,这就导致了 ChannelOutboundBuffer 中在 unflushedEntry 指针与 tailEntry 指针之间还可能会有数据。

之前我们清理的是 flushedEntry 指针与 tailEntry 指针之间的数据,这里大家需要注意区分。

所以还需要调用 ChannelOutboundBuffer#close 方法将这一部分数据全部清理掉。

public final class ChannelOutboundBuffer {

  void close(final Throwable cause, final boolean allowChannelOpen) {
        if (inFail) {
            channel.eventLoop().execute(new Runnable() {
                @Override
                public void run() {
                    close(cause, allowChannelOpen);
                }
            });
            return;
        }

        inFail = true;

        if (!allowChannelOpen && channel.isOpen()) {
            throw new IllegalStateException("close() must be invoked after the channel is closed.");
        }

        if (!isEmpty()) {
            throw new IllegalStateException("close() must be invoked after all flushed writes are handled.");
        }

        // Release all unflushed messages.
        //循环清理channelOutboundBuffer中的unflushedEntry,因为在执行关闭之前有可能用户有一些数据write进来,需要清理掉
        try {
            Entry e = unflushedEntry;
            while (e != null) {
                // Just decrease; do not trigger any events via decrementPendingOutboundBytes()
                int size = e.pendingSize;
                TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, -size);

                if (!e.cancelled) {
                    //释放unflushedEntry中的bytebuffer
                    ReferenceCountUtil.safeRelease(e.msg);
                    //通知unflushedEntry中的promise failed
                    safeFail(e.promise, cause);
                }
                e = e.recycleAndGetNext();
            }
        } finally {
            inFail = false;
        }
        //清理channel用于缓存JDK nioBuffer的 threadLocal缓存NIO_BUFFERS
        clearNioBuffers();
    }

}

当我们清理完 ChannelOutboundBuffer 中的残留数据之后,ChannelOutboundBuffer 中的内存水位线就会下降,由于当前是关闭操作,所以这里的 notifyWritability = false ,不需要触发 ChannelWritabilityChanged 事件。

关于对 ChannelOutboundBuffer 的详细操作,笔者已经在 一文搞懂 Netty 发送数据全流程 一文中详细介绍过了,忘记的同学可以在回顾下这篇文章。

2.1.5.3  触发 ChannelInactive 事件和 ChannelUnregistered 事件
在 Channel 关闭之后并清理完 ChannelOutboundBuffer 中遗留的待发送数据,就该在 Channel 的 pipeline 中触发 ChannelInactive 事件和 ChannelUnregistered 事件了。同样以下的这些操作也都是在 Reactor 线程中执行的。

       private void fireChannelInactiveAndDeregister(final boolean wasActive) {
            //wasActive && !isActive() 条件表示 channel的状态第一次从active变为 inactive
            //这里的wasActive = true  isActive()= false
            deregister(voidPromise(), wasActive && !isActive());
        }

这里传递进来的参数 wasActive = true ,在我们关闭 Channel 之前会通过 isActive() 先获取一次,在该方法中通过 wasActive && !isActive() 判断 Channel 是否是第一次从 active 状态变为 inactive 状态。如果是,则触发后续的 ChannelInactive 事件。

 

      private void deregister(final ChannelPromise promise, final boolean fireChannelInactive) {
            if (!promise.setUncancellable()) {
                return;
            }

            if (!registered) {
                safeSetSuccess(promise);
                return;
            }

            invokeLater(new Runnable() {
                @Override
                public void run() {
                    try {
                        //将channel从reactor中注销,reactor不在监听channel上的事件
                        doDeregister();
                    } catch (Throwable t) {
                        logger.warn("Unexpected exception occurred while deregistering a channel.", t);
                    } finally {
                        if (fireChannelInactive) {
                            //当channel被关闭后,触发ChannelInactive事件
                            pipeline.fireChannelInactive();
                        }

                        if (registered) {
                            //如果channel没有注册,则不需要触发ChannelUnregistered
                            registered = false;
                            //随后触发ChannelUnregistered
                            pipeline.fireChannelUnregistered();
                        }
                        //通知deRegisterPromise
                        safeSetSuccess(promise);
                    }
                }
            });
        }

注意这里又会调用 doDeregister() 方法将 Channel 从 Reactor 上注销,到目前为止,我们已经看到有三个地方执行注销 Channel 的操作了。

●   第一次是在 prepareToClose() 方法中,当我们设置了 SO_LINGER 选项后,为了防止 Reactor 线程在延时关闭期间,还在不停的自旋循环响应 OP_READ 事件和 OP_WRITE 事件从而造成浪费 CPU 资源,我们需要 doDeregister() 方法将 Channel 从 Reactor 上取消。
●   第二次是在真正的关闭 Channel 的时候,JDK 底层在关闭 SocketChannel 的时候又会将 Channel 从 Selector 上取消。应对关闭 SO_LINGER 选项的情况
●   第三次就是在本小节中,触发 ChannelUnregistered 事件之前,又会调用 doDeregister() 方法将 Channel 从 Reactor 上取消。
这里大家可能会有疑问,这第三次注销操作是应对哪种情况呢?

首先 JDK NIO 底层在将 Channel 从 Selector 上注销的时候做了防重处理,多次调用注销操作是没有影响的。

另外这个方法可能会在用户的 ChannelHandler 中被调用,因为用户的行为我们无法预知,用户可能在 Channel 关闭前调用,所以这里还是需要调用一次 doDeregister() 方法。为的就是应对用户在 ChannelHandler 中主动注销 Channel 同时不希望 Channel 关闭的场景。

        // 仅仅是注销 Channel,但是 Channel 不会关闭
        ctx.deregister();
        ctx.channel().deregister();

在调用完 doDeregister() 方法之后,netty 紧接着就会在 Channel 的 pipeline 中触发 ChannelInactive 事件以及 ChannelUnregistered 事件,并且这两个事件只会被触发一次。

在接收连接的时候,当 Channel 向 Reactor 注册成功之后,是先触发 ChannelRegistered 事件后触发 ChannelActive 事件。
在关闭连接的时候,当 Channel 从 Reactor 中取消注册之后,是先触发 ChannelInactive 事件后触发 ChannelUnregistered 事件

这里大家还需要注意的一个点是,以上的逻辑会封装在 Runnable 中被提交到 Reactor 的任务队列中延迟执行。那么这里为什么要延迟执行呢?

这里延后 deRegister 操作的原因是用于处理一种极端的异常情况,前边我们提到 Channel 的 deregister() 操作是可以在用户的 ChannelHandler 中执行的,用户行为是不可预知的。

我们想象一下这样的一个场景:假如当前 pipeline 中还有事件传播(比如正在处理编码解码),与此同时 deregister() 方法可能会在某个事件回调中被用户调用,导致其它事件在传播的过程中,Channel 被从 Reactor 上注销掉了。

并且同时 channel 又注册到新的 Reactor 上。如果此时旧的 Reactor 正在处理 pipeline 上的事件而旧 Reactor 还未处理完的数据理应继续在旧的 Reactor 中处理,如果此时我们立马执行 deRegister ,未处理完的数据就会在新的 Reactor 上处理,这样就会导致一个 handler 被多个 Reactor 线程处理导致线程安全问题。所以需要延后 deRegister 的操作。

 
到这里呢,关于 netty 如何处理 TCP 连接正常关闭的逻辑,笔者就为大家全部介绍完了,不过还留了一个小小的尾巴,就是当我们未设置 SO_LINGER 选项时,Channel 的关闭操作会直接在 Reactor 线程中执行。closeExecutor 这种情况下会是 null 。

        private void close(final ChannelPromise promise, final Throwable cause,
                           final ClosedChannelException closeCause, final boolean notify) {
            
            ...........省略重进入关闭流程处理........

            ...........省略Channel关闭前的准备工作........

            Executor closeExecutor = prepareToClose();
            if (closeExecutor != null) {
                ...........省略在closeExecutor中Channel关闭的逻辑........
            } else {
                try {
                    // Close the channel and fail the queued messages in all cases.
                    doClose0(promise);
                } finally {
                    if (outboundBuffer != null) {
                        // Fail all the queued messages.
                        outboundBuffer.failFlushed(cause, notify);
                        outboundBuffer.close(closeCause);
                    }
                }

                // 此时 Channel 已经关闭,如果此时用户还在执行 flush 操作
                // netty 则会在 flush 方法的处理中处理 Channel 关闭的情况
                // 所以这里 deRegister 操作需要延后到 flush 方法处理完之后
                if (inFlush0) {
                    invokeLater(new Runnable() {
                        @Override
                        public void run() {
                            fireChannelInactiveAndDeregister(wasActive);
                        }
                    });
                } else {
                    fireChannelInactiveAndDeregister(wasActive);
                }                 
            }
        }

这里可以看到其实逻辑都是一样的。都是先调用 doClose0 关闭 JDK NIO 底层的 SocketChannel ,然后清理 ChannelOutboundBuffer 中遗留的待发送数据,最后触发 ChannelInactive 事件和 ChannelUnregistered 事件。

我为 Netty 贡献源码 |(二)-鸿蒙开发者社区我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
3. TCP 连接的异常关闭


我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
在本文前边的内容中,我们介绍了 TCP 数据包中的 SYN 包,FIN 包,ACK 包的使用场景,它们都是通过 TCP 首部协议中的 8 位控制位来标识,不同的控制位代表不同的含义。

第二小节介绍的内容均属于 TCP 在正常情况下进行的连接的建立,发送数据,关闭连接。

而现实中情况往往是复杂的,TCP 连接不可能总是处于正常的状态,那么当 TCP 连接出现异常时,就需要有一种机制让我们来强制关闭连接,这个就是本小节要介绍的 RST 包用于异常情况下强制关闭 TCP 连接。

由于 RST 包是用来处理 TCP 连接的异常情况的,所以当本端发送一个 RST 包给对端之后,并不需要对端回复 ACK 确认包。

通讯方不管是发出或者是收到一个 RST 包 ,都会导致内存,端口等连接资源被释放,并且跳过正常的 TCP 四次挥手关闭流程直接强制关闭,Socket 缓冲区的数据来不及处理直接被丢弃。

当通讯端收到一个 RST 包后,如果仍然对 Socket 进行读取,那么就会抛出 connection has been reset by the peer 异常,如果仍然对 Socket 进行写入,就会抛出 broken pipe 异常。应用程序通过这样的方式来感知内核是否收到 RST 包。

发送 RST 强制关闭连接,这将导致之前已经发送但尚未送达的、或是已经进入对端 Socket 接收缓冲区但还未被对端应用程序处理的数据被无条件丢弃,导致对端应用程序可能会出现异常。

说了这么多,那么究竟会有哪些场景导致需要发送 RST 来强制关闭连接呢?下面笔者就来为大家一一梳理下:

3.1 TCP 连接队列已满
我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
我们先根据上面这副图来看一下一个正常的 TCP 连接建立的过程:

1. 客户端向服务端发送 SYN 包请求建立 TCP 连接。客户端连接状态变为 SYN_SENT 状态。
2. 服务端收到 SYN 包之后,服务端连接状态变为 SYN_RECV 状态。随后会创建轻量级 request_sock 结构来表示连接信息(里面能唯一确定某个客户端发来的 SYN 的信息),并将这个 request_sock 结构放入 TCP 的半连接队列 SYN_Queue 中,TCP 内核协议栈发送 SYN+ACK 包给客户端。
3. 客户端的 TCP 内核协议栈收到服务端发送过来的 SYN+ACK 后,随即回复 ACK 包给服务端。此时客户端连接状态变为 ESTANLISHED 状态。
4. 服务端收到客户端的 ACK 包之后,从半连接队列中查找是否有代表该客户端连接的轻量级 request_sock 结构,如果有,连接状态变为 ESTABLISHED 状态,随后会从半连接队列 SYN-Queue 中将 request_socket 结构取出移动到全连接队列 ACCEPT-Queue 中。
5. 用户进程的 accpet 系统调用根据监听 Socket 克隆出一个真正的连接 Socket 然后返回。
从 TCP 建立连接的过程我们看到,这里涉及到两个重要的队列,一个存放客户端 SYN 信息的半连接队列 SYN-Queue ,另一个是存放完成三次握手的客户端连接信息的全连接队列 ACCEPT-Queue 。

那么只要是队列它就会有长度的限制,就可能会满。那么在这两个连接队列已满的状况下,又会发生什么情况呢?

3.1.1 半连接队列 SYN-Queue 已满
我为 Netty 贡献源码 |(二)-鸿蒙开发者社区假设现在有大量的客户端在向服务端发送 SYN 包请求建立连接,但是这些客户端比较坏,在收到服务端的 SYN+ACK 包之后就是不回复 ACK 包给服务端,而服务端一直收不到客户端的 ACK 包,所以就会重传 SYN+ACK 包给客户端,重传次数由内核参数 tcp_synack_retries 限制,默认为 5 次。

$ cat /proc/sys/net/ipv4/tcp_synack_retries
5

这 5 次的重传时间间隔为 1s , 2s , 4s , 8s , 16s ,总共 31s ,而第 5 次重传的 SYN+ACK 包发出后还要等 32s 才能知道第 5 次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 63s ,TCP 才会把断开这个连接,并从半连接队列中移除对应的 request_sock 。

我们可以看到 TCP 内核协议栈需要等待 63s 的时间才能断开这个半连接,假设这 63s 内又有大量的客户端这样子搞事情,那么很快服务端的半连接队列 SYN-Queue 堆积的 request_sock 就会越来越多最终溢出。

当半连接队列溢出之后,再有正常的客户端连接进来之后,内核协议栈默认情况下就会直接丢弃 SYN 包,导致服务端无法处理正常客户端的请求,这就叫做 SYN Flood 攻击。

有一个内核参数 net.ipv4.tcp_syncookies 可以影响内核处理半连接队列溢出时的行为:

●   net.ipv4.tcp_syncookies = 0 :服务端直接丢弃客户端发来的 SYN 包。
●   net.ipv4.tcp_syncookies = 1 :如果此时全连接队列 ACEPT-Queue 也满了,并且 qlen_young 的值大于 1 ,那么直接丢弃 SYN 包,否则就生成 syncookie(一个特别的 sequence number )然后作为 SYN + ACK 包中的序列号返回给客户端。并输出 "possible SYN flooding on port . Sending cookies."。

qlen_young 表示目前半连接队列中,没有进行 SYN+ACK 包重传的连接数量。

随后客户端会在 ACK 包中将这个 syncookie 带上回复给服务端,服务端校验 syncookie ,并根据 syncookie 的信息构造 request_sock 结构放入全连接队列中。

从以上过程我们可以看出在开启 tcp_syncookies 的情况下,服务端利用 syncookie 可以绕过半连接队列从而完成建立连接的过程。我们可以利用这种方式来防御 SYN Flood 攻击。

但是 tcp_syncookies 不适合用在服务端负载很高的场景,因为在启用 tcp_syncookies 的时候,服务端在发送 SYN+ACK 包之前,会要求客户端在短时间内回复一个序号,这个序号包含客户端之前发送 SYN 包内的信息,比如 IP 和端口。

如果客户端回复的这个序号是正确的,那么服务端就认为这个客户端是正常的,随后就会发送携带 syncookie 的 SYN+ACK 包给客户端。如果客户端不回复这个序号或者序号不正确,那么服务端就认为这个客户端是不正常的,直接丢弃连接不理会。

从这个过程中,我们可以看出当启用 tcp_syncookies 的时候,这个建立连接的过程并不是一个正常的 TCP 三次握手的过程,因为服务端在发送 SYN+ACK 包之前还需要等待客户端回复一个序号,这就产生了一定的延迟,所以 tcp_syncookies 不适合用在服务端负载很高的场景,但是一般的负载情况还是比较有效防御 SYN Flood 攻击的方式。

除此之外,我们还可以调整以下内核参数来防御 SYN Flood 攻击

●   增大半连接队列容量 tcp_max_syn_backlog 。设置比默认 256 更大的一个数值。
●   减少 SYN+ACK 重试次数 tcp_synack_retries 。
3.1.2 全连接队列 ACCEPT-Queue 已满
当服务端的负载比较大并且从全连接队列中 accept 连接处理的比较慢,同时又有大量新的客户端连接上来的时候,就会导致 TCP 全连接队列溢出。

内核参数 net.ipv4.tcp_abort_on_overflow 会影响内核协议栈处理全连接队列溢出的行为。

当客户端发来三次握手最后一个 ACK 包时,但此时服务端全连接队列已满:

●   当 tcp_abort_on_overflow = 0 时,服务端内核协议栈会将该连接标记为 acked 状态,但仍保留在 SYN-Queue 中,并开启 SYN+ACK 重传机制。当 SYN+ACK 包的重传次数超过 net.ipv4.tcp_synack_retries 设置的值时,再将该连接从 SYN queue 中删除。但是此时在客户端的视角来说,连接已经建立成功了。客户端并不知道此时 ACK 包已经被服务端所忽略,如果此时向服务端发送数据的话,服务端会回复 RST 给客户端。我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
●   当 tcp_abort_on_overflow = 1 时, 服务端TCP 协议栈直接回复 RST 包,并直接从 SYN-Queue 中删除该连接信息。
我为 Netty 贡献源码 |(二)-鸿蒙开发者社区


面对全连接队列溢出的情况,我们需要及时增大全连接队列的长度,而全连接队列的长度由两个参数控制:

●   内核参数 net.core.somaxconn,默认 128 。
●   listen 系统调用方法参数 backlog 。

int listen(int sockfd, int backlog)

在 Netty 中我们可以通过如下配置指定:

    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
     .channel(NioServerSocketChannel.class)
     .option(ChannelOption.SO_BACKLOG, 全连接队列长度)

全连接队列 ACCEPT-Queue 的长度由 min(backlog, somaxconn) 决定,所以当全连接队列满时,我们需要检查如下设置:

●   调整内核参数 net.core.somaxconn。
●   检查应用程序中的 backlog 参数。
●   设置 tcp_abort_on_overflow = 1 。
3.2 连接未被监听的端口
我为 Netty 贡献源码 |(二)-鸿蒙开发者社区
当客户端 Connect 一个未被监听的远端服务端口,则会收到对端发来的一个 RST 包。

客户端要连接的端口未被监听,有两种情况:

●   该端口在服务端从来没有应用程序监听过。
●   服务端监听该端口的应用程序崩溃挂掉了。

分类
标签
已于2022-8-9 17:24:35修改
收藏
回复
举报
回复
    相关推荐