前言

最近在开发中用到netty,发现netty自己提供了native socket transport.Netty的核心开发者 Norman Maurer的回答https://stackoverflow.com/questions/23465401/why-native-epoll-support-is-introduced-in-netty,Netty的 epoll transport使用 epoll edge-triggered 而 java的 nio 使用 level-triggered.另外netty epoll transport 暴露了更多的nio没有的配置参数, 如 TCP_CORK, SO_REUSEADDR等等

epoll的事件派发接口可以运行在两种模式下:边缘触发(edge-triggered 简称 ET)和水平触发(level-triggered 简称LT):LT是默认的模式,ET是“高速”模式。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,应用程序read一个fd的时候一定要把它的buffer读光。所以ET模式效率更高。

我对SO_RESUEADDR比较感兴趣:参照这篇文章socket-options-so-reuseaddr-and-so-reuseport-how-do-they-differ-do-they-mean总结一下

背景

TCP/UDP是由以下五元组唯一地识别的:
{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}
这些数值组成的组合可以唯一地确一个连接。对于任意连接,这五个值都不能完全相同。否则的话操作系统就无法区别这些连接了。

BSD

现在的所有操作系统socket实现都参照了BSD的socket实现,然后各自布发展了一些新特性。

SO_REUSEADDR

如果在一个socket绑定到某一地址和端口之前设置了其SO_REUSEADDR的属性,那么除非本socket与产生了尝试与另一个socket绑定到完全相同的源地址和源端口组合的冲突,否则的话这个socket就可以成功的绑定这个地址端口对。SO_REUSEADDR主要改变了系统对待通配符IP地址冲突的方式。如果不用SO_REUSEADDR的话,如果我们将socketA绑定到0.0.0.0:8080,那么任何将本机其他socket绑定到端口8080的举动都不会成功。因为0.0.0.0是一个通配符IP地址,意味着任意一个IP地址,所以任何其他本机上的IP地址都被系统认为已被占用。果设置了SO_REUSEADDR选项,因为0.0.0.0:21和192.168.1.1:21并不是完全相同的地址端口对(其中一个是通配符IP地址,另一个是一个本机的具体IP地址),所以这样的绑定是可以成功的。需要注意的是,无论socketA和socketB初始化的顺序如何,只要设置了SO_REUSEADDR,绑定都会成功;而只要没有设置SO_REUSEADDR,绑定都不会成功。

SO_REUSEADDR socketA socketB Result
ON / OFF 192.168.1.1:21 192.168.1.1:21 ERROR
ON / OFF 192.168.1.1:21 10.0.1.1:21 OK
ON / OFF 10.0.1.1:21 192.168.1.1:21 OK
OFF 192.168.1.1:21 0.0.0.0:21 ERROR
OFF 0.0.0.0:21 192.168.1.1:21 ERROR
ON 192.168.1.1:21 0.0.0.0:21 OK
ON 0.0.0.0:21 192.168.1.1:21 OK
ON / OFF 0.0.0.0:21 0.0.0.0:21 ERROR

这个表格假定socketA已经成功地绑定了表格中对应的地址,然后socketB被初始化了,其SO_REUSEADDR设置的情况如表格第一列所示,然后socketB试图绑定表格中对应地址。Result列是其绑定的结果。如果第一列中的值是ON/OFF,那么SO_REUSEADDR设置与否都与结果无关。

上面讨论了SO_REUSEADDR对通配符IP地址的作用,但其并不只有这一作用。其另一作用也是为什么大家在进行服务器端编程的时候会采用SO_REUSEADDR选项的原因。为了理解其另一个作用及其重要应用,我们需要先更深入地讨论一下TCP协议的工作原理。

每一个socket都有其相应的发送缓冲区(buffer)。当成功调用其send()方法的时候,实际上我们所要求发送的数据并不一定被立即发送出去,而是被添加到了发送缓冲区中。对于UDP socket来说,即使不是马上被发送,这些数据一般也会被很快发送出去。但对于TCP socket来说,在将数据添加到发送缓冲区之后,可能需要等待相对较长的时间之后数据才会被真正发送出去。因此,当我们关闭了一个TCP socket之后,其发送缓冲区中可能实际上还仍然有等待发送的数据。但此时因为send()返回了成功,我们的代码认为数据已经实际上被成功发送了。如果TCP socket在我们调用close()之后直接关闭,那么所有这些数据都将会丢失,而我们的代码根本不会知道。但是,TCP是一个可靠的传输层协议,直接丢弃这些待传输的数据显然是不可取的。实际上,如果在socket的发送缓冲区中还有待发送数据的情况下调用了其close()方法,其将会进入一个所谓的TIME_WAIT状态。在这个状态下,socket将会持续尝试发送缓冲区的数据直到所有数据都被成功发送或者直到超时,超时被触发的情况下socket将会被强制关闭。

操作系统的kernel在强制关闭一个socket之前的最长等待时间被称为延迟时间(Linger Time)。在大部分系统中延迟时间都已经被全局设置好了,并且相对较长(大部分系统将其设置为2分钟)。我们也可以在初始化一个socket的时候使用SO_LINGER选项来特定地设置每一个socket的延迟时间。我们甚至可以完全关闭延迟等待。但是需要注意的是,将延迟时间设置为0(完全关闭延迟等待)并不是一个好的编程实践。因为优雅地关闭TCP socket是一个比较复杂的过程,过程中包括与远程主机交换数个数据包(包括在丢包的情况下的丢失重传),而这个数据包交换的过程所需要的时间也包括在延迟时间中。如果我们停用延迟等待,socket不止会在关闭的时候直接丢弃所有待发送的数据,而且总是会被强制关闭(由于TCP是面向连接的协议,不与远端端口交换关闭数据包将会导致远端端口处于长时间的等待状态)。所以通常我们并不推荐在实际编程中这样做。TCP断开连接的过程超出了本文讨论的范围,如果对此有兴趣,可以参考这个页面。并且实际上,如果我们禁用了延迟等待,而我们的程序没有显式地关闭socket就退出了,BSD(可能包括其他系统)会忽略我们的设置进行延迟等待。例如,如果我们的程序调用了exit()方法,或者其进程被使用某个信号终止了(包括进程因为非法内存访问之类的情况而崩溃)。所以我们无法百分之百保证一个socket在所有情况下忽略延迟等待时间而终止。

这里的问题在于操作系统如何对待处于TIME_WAIT阶段的socket。如果SO_REUSEADDR选项没有被设置,处于TIME_WAIT阶段的socket任然被认为是绑定在原来那个地址和端口上的。直到该socket被完全关闭之前(结束TIME_WAIT阶段),任何其他企图将一个新socket绑定该该地址端口对的操作都无法成功。这一等待的过程可能和延迟等待的时间一样长。所以我们并不能马上将一个新的socket绑定到一个刚刚被关闭的socket对应的地址端口对上。在大多数情况下这种操作都会失败。

然而,如果我们在新的socket上设置了SO_REUSEADDR选项,如果此时有另一个socket绑定在当前的地址端口对且处于TIME_WAIT阶段,那么这个已存在的绑定关系将会被忽略。事实上处于TIME_WAIT阶段的socket已经是半关闭的状态,将一个新的socket绑定在这个地址端口对上不会有任何问题。这样的话原来绑定在这个端口上的socket一般不会对新的socket产生影响。但需要注意的是,在某些时候,将一个新的socket绑定在一个处于TIME_WAIT阶段但仍在工作的socket所对应的地址端口对会产生一些我们并不想要的,无法预料的负面影响。但这个问题超过了本文的讨论范围。而且幸运的是这些负面影响在实践中很少见到。

最后,关于SO_REUSEADDR,我们还要注意的一件事是,以上所有内容只要我们对新的socket设置了SO_REUSEADDR就成立。至于原有的已经绑定在当前地址端口对上的,处于或不处于TIME_WAIT阶段的socket是否设置了SO_REUSEADDR并无影响。决定bind操作是否成功的代码仅仅会检查新的被传递到bind()方法的socket的SO_REUSEADDR选项。其他涉及到的socket的SO_REUSEADDR选项并不会被检查。

SO_REUSEPORT

SO_REUSEPORT并不等于SO_REUSEADDR。这么说的含义是如果一个已经绑定了地址的socket没有设置SO_REUSEPORT,而另一个新socket设置了SO_REUSEPORT且尝试绑定到与当前socket完全相同的端口地址对,这次绑定尝试将会失败。同时,如果当前socket已经处于TIME_WAIT阶段,而这个设置了SO_REUSEPORT选项的新socket尝试绑定到当前地址,这个绑定操作也会失败。为了能够将新的socket绑定到一个当前处于TIME_WAIT阶段的socket对应的地址端口对上,我们要么需要在绑定之前设置这个新socket的SO_REUSEADDR选项,要么需要在绑定之前给两个socket都设置SO_REUSEPORT选项。当然,同时给socket设置SO_REUSEADDR和SO_REUSEPORT选项是也是可以的。

SO_REUSEPORT是在SO_REUSEADDR之后被添加到BSD系统中的。这也是为什么现在有些系统的socket实现里没有SO_REUSEPORT选项。因为它们在这个选项被加入BSD系统之前参考了BSD的socket实现。而在这个选项被加入之前,BSD系统下没有任何办法能够将两个socket绑定在完全相同的地址端口对上。

FreeBSD/OpenBSD/NetBSD

所有这些系统都是参考了较新的原生BSD系统代码。所以这三个系统提供与BSD完全相同的socket选项,这些选项的含义与原生BSD完全相同。

MacOS X

MacOS X的核心代码实现是基于较新版本的原生BSD的BSD风格的UNIX,所以MacOS X提供与BSD完全相同的socket选项,并且它们的含义也与BSD系统相同。

iOS

iOS事实上是一个略微改造过的MacOS X,所以适用于MacOS X的也适用于iOS。

Linux

在Linux3.9之前,只有SO_REUSEADDR选项存在。这个选项的作用基本上同BSD系统下相同。但其仍有两个重要的区别。

第一个区别是如果一个处于监听(服务器)状态下的TCP socket已经被绑定到了一个通配符IP地址和一个特定端口下,那么不论这两个socket有没有设置SO_REUSEADDR选项,任何其他TCP socket都无法再被绑定到相同的端口下。即使另一个socket使用了一个具体IP地址(像在BSD系统中允许的那样)也不行。而非监听(客户)TCP socket则无此限制。

第二个区别是对于UDP socket来说,SO_REUSEADDR的作用和BSD中SO_REUSEPORT完全相同。所以两个UDP socket如果都设置了SO_REUSEADDR的话,它们就可以被绑定在一组完全相同的地址端口对上。

Linux3.9加入了SO_REUSEPORT选项。只要所有socket(包括第一个)在绑定地址前设置了这个选项,两个或多个,TCP或UDP,监听(服务器)或非监听(客户)socket就可以被绑定在完全相同的地址端口组合下。同时,为了防止端口劫持(port hijacking),还有一个特别的限制:所有试图绑定在相同的地址端口组合的socket必须属于拥有相同用户ID的进程。所以一个用户无法从另一个用户那里“偷窃”端口。

除此之外,对于设置了SO_REUSEPORT选项的socket,Linux kernel还会执行一些别的系统所没有的特别的操作:对于绑定于同一地址端口组合上的UDP socket,kernel尝试在它们之间平均分配收到的数据包;对于绑定于同一地址端口组合上的TCP监听socket,kernel尝试在它们之间平均分配收到的连接请求(调用accept()方法所得到的请求)。这意味着相比于其他允许地址复用但随机将收到的数据包或者连接请求分配给连接在同一地址端口组合上的socket的系统而言,Linux尝试了进行流量分配上的优化。比如一个简单的服务器进程的几个不同实例可以方便地使用SO_REUSEPORT来实现一个简单的负载均衡,而且这个负载均衡有kernel负责, 对程序来说完全免费!

也就是说可以在同一个机器上,针对统一端口和ip启动多个进程,操作系统负责在这些进程中负载均衡

Android

Android的核心部分是略微修改过的Linux kernel,所以所有适用于Linux的操作也适用于Android。

Windows

Windows仅有SO_REUSEADDR选项。在Windows中对一个socket设置SO_REUSEADDR的效果与在BSD下同时对一个socket设置SO_REUSEPORT和SO_REUSEADDR相同。但其区别在于:即使另一个已绑定地址的socket并没有设置SO_REUSEADDR,一个设置了SO_REUSEADDR的socket总是可以绑定到与另一个已绑定的socket完全相同的地址端口组合上。这个行为可以说是有些危险的。因为它允许了一个应用从另一个引用已连接的端口上偷取数据。微软意识到了这个问题,因此添加了另一个socket选项:SO_EXCLUSIVEADDRUSE。对一个socket设置SO_EXCLUSIVEADDRUSE可以确保一旦该socket绑定了一个地址端口组合,任何其他socket,不论设置SO_REUSEADDR与否,都无法再绑定当前的地址端口组合。

Solaris

Solaris是SunOS的继任者。SunOS从某种程度上来说也是一个较早版本的BSD的一个支路。因此Solaris只提供SO_REUSEADDR,且其表现和BSD系统中基本相同。据我所知,在Solaris系统中无法实现与SO_REUSEPORT相同的功能。这意味着在Solaris中无法将两个socket绑定到完全相同的地址端口组合下。

与Windows类似的是,Solaris也为socket提供独占绑定的选项——SO_EXCLBIND。如果一个socket在绑定地址前设置了这个选项,即使其他socket设置了SO_REUSEADDR也将无法绑定至相同地址。例如:如果socketA绑定在了通配符IP地址下,而socketB设置了SO_REUSEADDR且绑定在一个具体IP地址和与socketA相同的端口的组合下,这个操作在socketA没有设置SO_EXCLBIND的情况下会成功,否则会失败。