有没有办法让多个进程共享一个监听套接字?

103
在套接字编程中,您创建一个监听套接字,然后为每个连接的客户端获得一个普通流套接字,您可以使用它来处理客户端的请求。操作系统在后台管理传入连接的队列。
默认情况下,两个进程不能同时绑定到同一端口。
我想知道是否有一种方法(在任何众所周知的操作系统上,特别是Windows),可以启动多个进程实例,并使它们全部绑定到套接字,从而有效地共享队列。然后,每个进程实例都可以是单线程的;当接受新连接时,它只会阻塞。当客户端连接时,其中一个空闲的进程实例将接受该客户端。
这将允许每个进程具有非常简单的单线程实现,除非通过显式共享内存进行共享,否则不共享任何内容,并且用户将能够通过启动更多实例来调整处理带宽。
是否存在这样的功能?
编辑:对于那些问“为什么不使用线程?”的人,显然线程是一种选择。但是,在单个进程中使用多个线程时,所有对象都是可共享的,必须非常小心地确保对象要么不共享,要么仅对一个线程可见,要么绝对不变,并且大多数流行的语言和运行时都缺乏内置的支持来管理这种复杂性。
通过启动一些相同的工作进程,您将获得一个并发系统,其中默认值为不共享,从而使构建正确且可扩展的实现更加容易。

2
我同意,多个进程可以更容易地创建正确和强健的实现。可扩展性,我不确定,这取决于您的问题域。 - MarkR
10个回答

100
你可以在Linux和甚至Windows上将一个套接字(socket)共享给两个或多个进程。
在Linux(或POSIX类型的操作系统)下,使用fork()会导致派生出的子进程拥有父进程的所有文件描述符的副本。任何它不关闭的文件描述符都将继续被共享,并且(例如使用TCP监听套接字)可以用于接受客户端的新套接字。这就是许多服务器,包括大多数情况下的Apache,的工作方式。
在Windows上,基本上也是相同的,只是没有fork()系统调用,因此父进程需要使用CreateProcess或其他方法创建一个子进程(当然可以使用相同的可执行文件),并需要传递一个可继承的句柄。
使侦听套接字成为可继承的句柄不是完全微不足道的活动,但也不太棘手。需要使用DuplicateHandle()创建一个副本句柄(仍在父进程中),该副本句柄将具有可继承标志。然后,您可以将该句柄在CreateProcess中的STARTUPINFO结构中作为STDIN、OUT或ERR句柄(假设您不想将其用于其他任何事情)传递给子进程。
编辑:
阅读MDSN库后,似乎WSADuplicateSocket是更健壮或正确的机制;这仍然不是微不足道的,因为父/子进程需要通过一些IPC机制(尽管这可能只是文件系统中的一个简单文件)确定需要复制哪个句柄。
澄清:
回答OP的最初问题,多个进程不能bind();只有原始父进程会调用bind(),listen()等函数,子进程只处理accept()、send()、recv()等请求。

3
通过指定 SocketOptionName.ReuseAddress 套接字选项,多个进程可以绑定同一个地址。 - sipsorcery
7
进程比线程更加笨重,但由于它们只显式共享东西,需要的同步较少,这使得编程更加容易,并且在某些情况下甚至可能更加高效。 - MarkR
12
此外,如果子进程崩溃或出现某种故障,很少会影响到父进程。 - MarkR
4
值得一提的是,在Linux中,您可以使用Unix套接字将套接字传递给其他程序,而无需使用fork()且不具有父/子关系。 - Rahly
就Linux而言,没有线程。或者更确切地说,线程被实现为进程。或者更确切地说,进程和线程被重构为具有完全相同的实现。真正的区别在于父子进程之间共享多少状态。对于两者,您都可以选择共享更多或共享更少的状态。因此,您可以从线程开始创建进程,或者从进程开始创建线程,甚至可以半途而废,拥有半进程或半线程。 - slebetman
显示剩余6条评论

38

大多数其他人已经提供了这个方法的技术原理。这里是一些 Python 代码,您可以运行以证明其有效性:

import socket
import os

def main():
    serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    serversocket.bind(("127.0.0.1", 8888))
    serversocket.listen(0)

    # Child Process
    if os.fork() == 0:
        accept_conn("child", serversocket)

    accept_conn("parent", serversocket)

def accept_conn(message, s):
    while True:
        c, addr = s.accept()
        print 'Got connection from in %s' % message
        c.send('Thank you for your connecting to %s\n' % message)
        c.close()

if __name__ == "__main__":
    main()

请注意,确实有两个进程ID在监听:

$ lsof -i :8888
COMMAND   PID    USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Python  26972 avaitla    3u  IPv4 0xc26aa26de5a8fc6f      0t0  TCP localhost:ddi-tcp-1 (LISTEN)
Python  26973 avaitla    3u  IPv4 0xc26aa26de5a8fc6f      0t0  TCP localhost:ddi-tcp-1 (LISTEN)

这里是运行telnet和程序后的结果:

$ telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Thank you for your connecting to parent
Connection closed by foreign host.
$ telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Thank you for your connecting to child
Connection closed by foreign host.
$ telnet 127.0.0.1 8888
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Thank you for your connecting to parent
Connection closed by foreign host.

$ python prefork.py 
Got connection from in parent
Got connection from in child
Got connection from in parent

2
所以对于一个连接,父进程或子进程中的一个会获得它。但是谁获得连接是不确定的,对吧? - Hot.PxL
1
是的,我认为这取决于操作系统安排运行哪个进程。 - Anil Vaitla

16

我想补充一点,Unix/Linux上的套接字可以通过AF__UNIX套接字(进程间套接字)进行共享。似乎会创建一个新的套接字描述符,这是原始描述符的别名。将此新的套接字描述符通过AFUNIX套接字发送到其他进程。在某些情况下,这非常有用,例如进程不能fork()来共享其文件描述符时。例如,当使用由于线程问题防止此操作的库时。您应该创建一个Unix域套接字并使用libancillary发送描述符。

参见:

创建AF_UNIX套接字:

示例代码:


13

看起来MarkR和zackthehack已经完全回答了这个问题,但我想补充一点,Nginx是监听套接字继承模型的一个例子。

这里有一个很好的描述:

         Implementation of HTTP Auth Server Round-Robin and
                Memory Caching for NGINX Email Proxy

                            June 6, 2007
             Md. Mansoor Peerbhoy <mansoor@zimbra.com>

...

Flow of an NGINX worker process

After the main NGINX process reads the configuration file and forks into the configured number of worker processes, each worker process enters into a loop where it waits for any events on its respective set of sockets.

Each worker process starts off with just the listening sockets, since there are no connections available yet. Therefore, the event descriptor set for each worker process starts off with just the listening sockets.

(NOTE) NGINX can be configured to use any one of several event polling mechanisms: aio/devpoll/epoll/eventpoll/kqueue/poll/rtsig/select

When a connection arrives on any of the listening sockets (POP3/IMAP/SMTP), each worker process emerges from its event poll, since each NGINX worker process inherits the listening socket. Then, each NGINX worker process will attempt to acquire a global mutex. One of the worker processes will acquire the lock, whereas the others will go back to their respective event polling loops.

Meanwhile, the worker process that acquired the global mutex will examine the triggered events, and will create necessary work queue requests for each event that was triggered. An event corresponds to a single socket descriptor from the set of descriptors that the worker was watching for events from.

If the triggered event corresponds to a new incoming connection, NGINX accepts the connection from the listening socket. Then, it associates a context data structure with the file descriptor. This context holds information about the connection (whether POP3/IMAP/SMTP, whether the user is yet authenticated, etc). Then, this newly constructed socket is added into the event descriptor set for that worker process.

The worker now relinquishes the mutex (which means that any events that arrived on other workers can proceeed), and starts processing each request that was earlier queued. Each request corresponds to an event that was signaled. From each socket descriptor that was signaled, the worker process retrieves the corresponding context data structure that was earlier associated with that descriptor, and then calls the corresponding call back functions that perform actions based on the state of that connection. For instance, in case of a newly established IMAP connection, the first thing that NGINX will do is to write the standard IMAP welcome message onto the
connected socket (* OK IMAP4 ready).

By and by, each worker process completes processing the work queue entry for each outstanding event, and returns back to its event polling loop. Once any connection is established with a client, the events usually are more rapid, since whenever the connected socket is ready for reading, the read event is triggered, and the corresponding action must be taken.


13

虽然不确定与原问题有多大关联,但在Linux内核3.9中,有一个补丁添加了一个TCP/UDP功能:TCP和UDP支持SO_REUSEPORT套接字选项;新的套接字选项允许同一主机上的多个套接字绑定到同一端口,并旨在提高运行在多核系统上的多线程网络服务器应用程序的性能。更多信息可以在参考链接中的LWN链接LWN SO_REUSEPORT in Linux Kernel 3.9中找到。

虽然SO_REUSEPORT选项是非标准的,但在其他许多UNIX系统(特别是BSD系统,该想法起源于此)中以类似形式可用。它似乎为在多核系统上运行的网络应用程序挤出最大性能而提供了有用的替代方案,而无需使用fork模式。


从LWN文章中看起来,SO_REUSEPORT几乎像是创建了一个线程池,其中每个套接字都在不同的线程上,但是组中只有一个套接字执行accept。您能确认组中的所有套接字都会获得数据的副本吗? - jww

4

3

有一个单一的任务,其唯一工作是监听传入连接。当收到连接时,它接受连接-这将创建一个单独的套接字描述符。接受的套接字传递给您可用的一个工作任务,而主任务则返回侦听。

s = socket();
bind(s);
listen(s);
while (1) {
  s2 = accept(s);
  send_to_worker(s2);
}

套接字如何传递给工作进程?请记住,工作进程是一个独立的进程。 - Daniel Earwicker
fork() 或者上面提到的其他想法。或者你可以完全将套接字 I/O 与数据处理分离;通过 IPC 机制将有效载荷发送到工作进程。OpenSSH 和其他 OpenBSD 工具使用这种方法(没有线程)。 - HUAGHAGUAH

3
在Windows(和Linux)下,一个进程可以打开一个套接字,然后将该套接字传递给另一个进程,以便该第二个进程也可以使用该套接字(如果它希望这样做,则可以继续传递它)。
关键的函数调用是WSADuplicateSocket()。
此函数会填充有关现有套接字的信息结构。 然后,通过您选择的IPC机制,将此结构传递给另一个现有进程(请注意,我说的是现有进程 - 当您调用WSADuplicateSocket()时,必须指定将接收发射的信息的目标进程)。
接收进程可以然后调用WSASocket(),传入此信息结构,并接收底层套接字的句柄。
现在两个进程都持有相同底层套接字的句柄。

2

如果你在Windows上使用HTTP协议,避免涉及复杂细节的另一种方法是使用HTTP.SYS。这允许多个进程在同一端口上监听不同的URL。在Server 2003/2008/Vista/7上,这就是IIS的工作方式,因此您可以与它共享端口。(在XP SP2上支持HTTP.SYS,但IIS5.1不使用它。)

其他高级API(包括WCF)也使用HTTP.SYS。


1

听起来你想要的是一个进程监听新客户端并在连接建立后将其交接。在跨线程执行此操作很容易,在 .Net 中,你甚至可以使用 BeginAccept 等方法来处理大量的管道工作。而在进程边界上进行连接交接则会变得复杂,并且没有任何性能优势。

或者你可以让多个进程绑定并监听同一个套接字。

TcpListener tcpServer = new TcpListener(IPAddress.Loopback, 10090);
tcpServer.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
tcpServer.Start();

while (true)
{
    TcpClient client = tcpServer.AcceptTcpClient();
    Console.WriteLine("TCP client accepted from " + client.Client.RemoteEndPoint + ".");
}

如果您启动两个进程,每个进程执行上述代码,它将起作用,并且第一个进程似乎会获得所有连接。如果第一个进程被杀死,则第二个进程将获得连接。像这样共享套接字,我不确定Windows如何决定哪个进程获得新连接,尽管快速测试确实指向最旧的进程首先获得它们。至于是否共享,如果第一个进程很忙或其他任何情况,我不知道。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接