IPC - Unix 域套接字

@高效码农  November 27, 2023

在上一篇文章中,我们讨论了命名管道机制来实现进程间通信。本文将介绍另一种称为 Unix 域套接字的内容。

套接字是 Unix 网络的抽象。当我们想到网络时,我们会想到沟通。构成互联网的工具主要涉及创建和维护计算机之间的通信管道。我们的操作系统提供了其中一些工具。既然这些都是通信工具,那么如果我们可以使用操作系统提供的一些高质量且可靠的工具来使进程能够相互聊天呢?好消息!事实证明它确实存在,这就是本文的主题。

快速网络入门

在讨论 Unix Domain Socket 之前,我们先快速讨论一下网络。我可以假设我们熟悉 OSI 网络层。主要层是物理层、链路层、网络层、传输层和应用层。

物理层(光纤、4G、5G 等)涉及通过无线电波和光等物理介质发送数据。链路层(以太网、WiFi 等)在网络之间传输数据。网络层(BGP、ICMP 等)关注通过最有效的路径路由数据。传输层(TCP、UDP 等)将数据传输到正确计算机上的正确应用程序进程。应用层(SMTP、HTTP、万种其他协议)处理用户和数据之间的交互。

当您通过网络发送数据时,数据从应用程序 -> 传输 -> 网络 -> 链路 -> 物理。当您通过网络接收数据时,它已经从物理 -> 链路 -> 网络 -> 传输 -> 应用程序进行。

进程间通信机制负责将数据从一个应用程序进程传输到计算机内的正确应用程序进程。这与传输层的作用非常相似。值得庆幸的是,我们的各种操作系统创建者意识到了这一点,并提供了一种基于传输层接口的机制。你没看错;Unix 域套接字建立在 Unix 传输层接口之上。现在,我厌倦了说传输层接口。这是相当拗口的。从现在起我将其称为 BSD 套接字,因为它们众所周知。

BSD 套接字

BSD/Berkeley/POSIX Sockets 是基于 Unix 的操作系统为互联网提供的 API。许多通过 Internet 进行通信的应用程序都使用 BSD 套接字,包括您最喜欢的 Web 浏览器和服务器。应用程序中直接或间接(通过其他库)使用的许多网络库都使用 BSD 套接字。该 API 如此流行且无处不在,以至于您最喜欢的编程语言提供了 C 编程语言版本的包装器。

In addition to being used for the internet, the BSD Sockets API is used for IPC. The API is so well-designed that most of its functions used to achieve network communication are the same as those used for IPC. Let’s look at some of these functions and their definitions:

  • socket(): Creates a socket/endpoint for communication. It sets the endpoint communication domain (Internet, IPC, and others) and semantics (TCP, UDP, raw) so that the OS can allocate the right resources to enable our communication. It returns an integer in some languages or an object in other languages.
  • bind(): Used on the server side. It maps a reference with a socket so that other processes in and out of our computer can connect. This reference is an IP address and port number for Internet communication or a file name for IPC.
  • listen(): Used on the server side too. It signifies that our socket is willing to accept incoming connections.
  • connect(): Used on the client side. It associates our socket with a server’s reference. It is this map that enables our client to talk to a server. In TCP, this function is also responsible for initiating the three-way handshake#TCP_three-way_handshake) to establish a connection. At the risk of sounding redundant, this reference can be an IP address and port number for Internet communication or a file name for IPC.
  • accept(): Used on the server side. Accepts an incoming connection and creates a new socket for this connection. This socket can be an integer in some languages or an object in others. Afterward, data is sent and received over this new socket.
  • send() and recv(): These functions are responsible for data transfer between peers.
  • close(): Releases resources allocated to a socket. In TCP, it initiates the four-way handshake that terminates the connection.

We won’t discuss functions like getaddrinfo(), freeaddrinfo(), and others because they aren’t necessary for IPC. You can learn more about them on Beej’s excellent guide to networking.

Unix Domain Sockets

I know it took so long to get here; I had to give context :-). Unix Domain Socket, which I’ll call UDS going forward is simply IPC over sockets. When two application processes communicate over UDS, they use the buffers allocated to each socket for message transfer. These buffers are set up by the OS when creating sockets.

The referencing socket on the client process is created by calling the socket() function. The server process creates a new referencing socket when it accept() a connection. The socket created by calling the socket() function on the server process, is never used for data transfer. Its sole responsibility is to listen for incoming connections.

An application process that wants other processes to connect to it needs a reference so they can send a connection request to it. Just like in Named Pipes, this reference is a file name. And just as in Named Pipes, nothing is ever written to this file. It is used only as a reference. This file is like your regular file; meaning it displays in your directory listing or your file explorer application. Here’s how it shows up

srwxr-xr-x  1 user  staff    0 Sep 29 17:13 udsocket

The s in the first column stands for socket. An important fact about a socket file is that using the open() function to access the file will return an error. A consequence of this is almost all application programs are unable to open the file.

An advantage of using a file as a reference is Unix file access rights and authorization applies to the file. You don’t have to add an authentication and authorization layer to UDS to provide security. You can use the OS file permissions for security.

Each peer socket has a read and write buffer. Message sent from one socket is copied from the write buffer of the sending socket to the read buffer of the receiving buffer. It implies that UDS is a bidirectional IPC mechanism, meaning each peer can read and write messages. An advantage of this setup is that a process can connect and communicate with multiple application processes simultaneously without application-level synchronization. The OS provides this synchronization for free, unlike some other IPC mechanisms!

Show me the code

Our example will demonstrate two Python processes, a server and a client. The client will send a “ping” message to the server, and the server will print it out.

Here’s the client

    import socket

    ROUNDS = 100

    def run():
        server_address = './udsocket'
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.connect(server_address)

        print(f"Connecting to {server_address}")
        i = 0
        msg = "ping".encode()
        while i < ROUNDS:
            sock.sendall(msg)
            print("Client: Sent ping")

            data = sock.recv(16)
            print(f"Client: Received {data.decode()}")
            i += 1
        sock.sendall("end".encode())
        sock.close()

The client process creates a socket object called sock. The socket family, which is the first parameter of the socket() function is set to AF_UNIX for the OS to create a UDS socket. Other families are AF_INET and AF_INET6 for IPv4 and IPv6 communication used for internet communication. The socket type(second parameter) is set to SOCK_STREAM; this guarantees reliable and two-way communication. This type is also used to specify TCP protocol for internet communication. Other types are SOCK_DGRAM for UDP and SOCK_RAW for raw sockets.

After the socket object is created by the OS, the client process connects to the server using the file name provided as a reference (udsocket in the code) by calling the connect() method. Once the connection is successful, it sends and receives a reply from the server a hundred times using the sendall and recv methods. Note that this data is a Python byte string and must be encoded and decoded.

After the loop ends, an “end” message is sent to the server process to signify the conclusion of the transfer. The connection is closed using the close() method.

Here’s the server code

    import os
    import socket


    def run():
        server_path = './udsocket'
        # Delete if the path does exist
        try:
            os.unlink(server_path)
        except OSError:
            pass

        server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        server.bind(server_path)

        server.listen(1)
        msg = "pong".encode()
        while True:
            connection, address = server.accept()
            print(f"Connection from {address}")
            while True:
                data = connection.recv(16).decode()
                if data != "ping":
                    break
                print(f"Server: Received {data}")
                connection.sendall(msg)
                print(f"Server: Sent pong")
            if data == "end":
                print("Server: Connection shutting down")
                connection.close()
            else:
                print(f"Server Error: received invalid message {data}, shutting down connection")
                connection.close()

服务器进程首先删除套接字文件以防止“地址已在使用”错误。它使用与客户端进程相同的函数调用和参数创建服务器套接字对象。然后通过调用该方法将套接字绑定到套接字文件bind()。之后,使用该listen()方法将其设置为监听模式。

然后服务器运行一个循环;在此循环内,使用该方法接受传入连接accept()。该方法返回一个新的套接字对象和一个地址。数据传输将使用这个新套接字。一旦此接受的连接成功,内部循环就会开始运行,其中使用该方法接收消息recv()并进行检查。如果是“ping”,它会回复使用该sendall()方法发送的“pong”消息。如果不是“ping”,则内循环结束,连接套接字关闭。

接收和发送的消息是Python字节字符串,必须进行编码和解码。

表现

UDS 相当快,但不如命名管道快。IPC-Bench在运行 Ubuntu 20.04.1 LTS 的 Intel(R) Core(TM) i5-4590S CPU @ 3.00GHz 上进行了每秒 127,582 条 1KB 消息的基准测试。这样的速度足以满足大多数进程的通信需求。

本地主机怎么样?

既然 UDS 像您的网络代码一样使用 BSD 套接字 API,为什么我们不使用 TCP/UDP 本地主机进行 IPC?当然,你可以使用它们。使用 TCP/UDP 进行 IPC 的一个优点是您可以使用您最喜欢的网络库,而不用摆弄 Sockets API。所以,这很简单而且熟悉,对吧?问题是你要承受性能成本。

当您使用 UDS 时,您的计算机知道通信是本地的,因此不会执行使用 TCP 完成的整个握手和确认。此外,您的数据不会经过整个 IP 堆栈机制。不必这样做对于 UDS 来说是一个巨大的性能提升。其他事情被绕过,我不会在这里解释,但要知道 UDS 比 TCP/UDP 本地主机更快。如果您仍然好奇,罗伯特·沃森(Robert Watson)的精彩解释会解释更多。

演示代码

您可以在GitHub上的 UDS 上找到我的代码。

结论

UDS是一种非常强大且可靠的IPC机制,这就是它被广泛使用的原因。如果您希望应用程序进程进行双向通信,并且不需要极高的性能,请使用它。

下一篇文章将讨论一种古老且非常有限的 IPC 机制,称为Signal。在那之前,照顾好自己并保持水分!✌



评论已关闭