C/Python/Go示例 | Socket Programing与RPC
Socket Programming介绍
Computer networking这个领域围绕着两台电脑或者同一台电脑内的不同进程之间的数据传输和信息交流,会涉及到许多有意思的话题,诸如怎么确保对方能收到信息,怎么应对数据丢失、被污染或者顺序混乱,怎么提高传输效率,怎么在多人协作的场景下应对交通堵塞,等等。这篇文章要聊的socket programming是这个领域内的内容,socket是计算机的接口,一台计算机的应用程序可以把数据放入到socket接口,传输到另一台计算机的socket接口。从应用程序的角度而言,它只在乎发送本地用户的数据和接受其他用户的数据,而并不在乎数据是怎么完成传输的。
考虑到网络是个庞大复杂的话题——互联网是世界上运行规模最大的计算机网络实现,但这个领域外的大多数公众对于互联网是如何运转的所知甚少,例如,当我们在浏览器内输入网址并敲击回车之后,究竟发生了什么才能让浏览器加载出缤纷绚丽的内容呈现到我们眼前,这实在是很有意思的话题——前辈们设计了网络的五层模型,来帮助人类有效地分工协作解决网络这个难题。模型的最顶层是应用层,是我们大多数人会打交道的层级。
🧑 Application Layer (HTTP, FTP, NFS, SMTP)
|
| 🔌 Socket API (e.g. your Go/C++/C/Python code calls)
│
🌐 Transport Layer (TCP / UDP)
│
📦 Internet Layer (IP)
│
🧰 Link Layer (Ethernet / Wi-Fi)
│
🧱 Physical Layer (Electrical signals, radio, fiber)
Socket是连接应用层和传输层的桥梁,应用程序可以调用一系列简单的Socket API来完成数据传输,实际的传输任务会由操作系统的Kernel负责。我常常感慨计算机领域中「封装」和「分层」的哲学思维很了不起,我们由此得以在前辈们搭建的基础上继续添砖加瓦,做出自己的微小贡献。难以想象如果为了刷小红书我们需要自己写一套操作系统、设计芯片、焊接电路、铺设网线电缆…
Socket API实际上是在调用操作系统内核的system call, 主要函数如下。注意到,网络传输总是会涉及到两方——发送请求讨要数据的client, 以及接受请求提供数据的server——因此我们需要区分下列这些system calls对于这两个socket的相应操作,我对每个system call都附上了对应的Linux manual链接。
-
client/server通用操作:
-
socket creates an endpoint for communication and returns a file descriptor
sockfd
that refers to that endpoint.-
int socket(int domain, int type, int protocol);
-
-
send send a message to a socket
-
ssize_t send(int sockfd, const void buf[.size], size_t size, int flags);
-
-
recv receive a message from a socket
-
ssize_t recv(int sockfd, void buf[.size], size_t size, int flags);
-
-
-
client建立连接:
- connect connects the socket referred to by the file descriptor
sockfd
to the address specified byaddr
.-
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
- connect connects the socket referred to by the file descriptor
-
server建立连接:
-
bind assigns the address specified by
addr
to the socket referred to by the file descriptorsockfd
. Traditionally, this operation is called “assigning a name to a socket”.-
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
-
listen marks the socket referred to by
sockfd
as a passive socket, that is, as a socket that will be used to accept incoming connection requests usingaccept
.-
int listen(int sockfd, int backlog);
-
-
accept accept a connection on a socket
-
int accept(int sockfd, struct sockaddr *_Nullable restrict addr, socklen_t *_Nullable restrict addrlen);
-
-
Socket Programming示例
下面我们来看看怎么使用编程语言,来调用上面这些socket programming中涉及到的system calls.
我要实现的应用是非常简单的echo server, 不管client发送什么信息,server都会原封不动地将其返还。我会分别使用C, Python和Go这三种语言来实现。注意到,虽然C是最底层、最接近计算机硬件的语言,但在socket programming的例子中,C和Python/Go同样都属于是相对高层的语言,因为它们都在调用相同的system calls.
C - server
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // for close()
#include <arpa/inet.h> // for inet_ntoa()
#include <sys/socket.h> // for socket(), bind(), listen(), accept()
#include <netinet/in.h> // for sockaddr_in#define PORT 9000
#define BUFFER_SIZE 1024int main() {int server_fd, client_fd;struct sockaddr_in server_addr, client_addr;char buffer[BUFFER_SIZE];// 1. Create a TCP socketserver_fd = socket(AF_INET, SOCK_STREAM, 0);if (server_fd < 0) {perror("socket");exit(1);}// 2. Bind the socket to an IP and portmemset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET; // IPv4server_addr.sin_addr.s_addr = INADDR_ANY; // Any interface (0.0.0.0)server_addr.sin_port = htons(PORT); // Convert port to network byte orderif (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {perror("bind");exit(1);}// 3. Listen for incoming connectionsif (listen(server_fd, 1) < 0) {perror("listen");exit(1);}printf("Echo server listening on port %d...\n", PORT);// 4. Accept a connectionsocklen_t client_len = sizeof(client_addr);client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);if (client_fd < 0) {perror("accept");exit(1);}printf("Client connected: %s:%d\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));// 5. Read data and echo it backwhile (1) {ssize_t bytes_read = read(client_fd, buffer, sizeof(buffer));if (bytes_read <= 0) break; // client closed or errorprintf("Received : %s", buffer);write(client_fd, buffer, bytes_read); // echo back}// 6. Close socketsclose(client_fd);close(server_fd);printf("Connection closed.\n");return 0;
}
C - client
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // for close()
#include <arpa/inet.h> // for inet_pton()
#include <sys/socket.h> // for socket(), connect()
#include <netinet/in.h> // for sockaddr_in#define PORT 9000
#define BUFFER_SIZE 1024int main() {int sock_fd;struct sockaddr_in server_addr;char buffer[BUFFER_SIZE];// 1. Create a TCP socketsock_fd = socket(AF_INET, SOCK_STREAM, 0);if (sock_fd < 0) {perror("socket");exit(1);}// 2. Set server addressmemset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {perror("inet_pton");exit(1);}// 3. Connect to serverif (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {perror("connect");close(sock_fd);exit(1);}printf("Connected to server on port %d.\n", PORT);// 4. Read input and send to serverwhile (fgets(buffer, sizeof(buffer), stdin) != NULL) {write(sock_fd, buffer, strlen(buffer));ssize_t bytes_received = read(sock_fd, buffer, sizeof(buffer) - 1);if (bytes_received <= 0) break;buffer[bytes_received] = '\0'; // null-terminateprintf("Echo from server: %s", buffer);}// 5. Close socketclose(sock_fd);printf("Disconnected.\n");return 0;
}
Python - server
import socketdef main():s = socket.socket()s.bind(("127.0.0.1", 9000))s.listen(1)conn, remote_addr = s.accept()print(f"Connected to remote: {remote_addr}")while True:data = conn.recv(1024)if not data:breakconn.sendall(data)if __name__ == '__main__':main()
Python - client
import socketdef main():s = socket.socket()s.connect(("127.0.0.1", 9000))while True:msg = input("Enter message: ")if not msg:breaks.sendall(msg.encode())data = s.recv(1024)print(f"Received: {data.decode()}")if __name__ == '__main__':main()
Go - server
package mainimport ("fmt""net""os"
)func handleConnection(conn net.Conn) {remoteAddr := conn.RemoteAddr()defer func(c net.Conn) {fmt.Println("Closing connection with remote: ", remoteAddr)c.Close()}(conn)buf := make([]byte, 1024)fmt.Println("Connected with remote: ", remoteAddr)for {n, err := conn.Read(buf)if err != nil {return}received := string(buf[:n])fmt.Println("Received: ", received)conn.Write([]byte(received))}
}func main() {fmt.Println("Starting echo server")listener, _ := net.Listen("tcp", ":9000")for {conn, _ := listener.Accept()go handleConnection(conn)}
}
Go - client
package mainimport ("bufio""fmt""net""os"
)func main() {conn, err := net.Dial("tcp", ":9000")if err != nil {fmt.Println("Error connecting to server:", err)return}defer conn.Close()fmt.Println("Connected to server. Type messages and press Enter:")scanner := bufio.NewScanner(os.Stdin)for scanner.Scan() {text := scanner.Text()_, err := conn.Write([]byte(text))if err != nil {fmt.Println("Write error:", err)break}// Read server responsebuf := make([]byte, 1024)n, err := conn.Read(buf)if err != nil {fmt.Println("Read error:", err)break}fmt.Println("Server replied:", string(buf[:n]))}
}
分析
代码风格
我们可以很明显地看到三份代码中的相同模式:server都是在 socket()
创建socket后通过 bind
/ listen
/ accept
建立连接,client则是创建socket后使用 connect
(不同语言下的封装不同,Go对应的是 Dial
)来访问server。
C调用system calls的风格最为淳朴
#include <sys/socket.h>server_fd = socket(AF_INET, SOCK_STREAM, 0);
而Python需要借助 os
module和CPython中的C bindings, Go则是要用 syscall
module(感兴趣可以逛逛源码中的封装)。
另一个代码风格的差别在于,C和Python例子中的server都是只能和单个client进行连接,而Go可以做到多个,得益于goroutine的设计,实现concurrency轻松自然。
关于client
接着来聊聊client, 注意到在server运行之后,我们可以用任意client代码来访问,例如运行Go client代码来访问Python server(这就是client-server思想的魅力之一,让双方可以采用自己喜欢的方式)。 再进一步,其实我们甚至并不需要写client代码,因为我们这个极度简化的server并不在乎client究竟是什么——是一段C/Python/Go程序,又或者是什么其他语言——只要对方使用相同的地址,以及相同的传输层协议(通常是TCP),就足够了。
我们可以使用Unix命令行工具netcat来作为一个极度轻盈便捷的client
% netcat 127.0.0.1 9000
Hi there
Hi there
Don't worry, be happy :)
Don't worry, be happy :)
达成的效果和C/Py/Go的client一致,区别无非在于在这些编程语言中我们可以增添一些个性化操作,例如可以将字符串搭配上 "Server replied: "
之后再print出来。
关于address
绝大多数情况下,socket都是和某个IP地址和port(在上面例子中我统一用port 9000来运行server)绑定起来,这一类是Internet (TCP/IP) sockets, 另一类常见的是Unix domain sockets, 它们是和特殊的文件路径绑定起来的。
例如,Docker其实是通过 /var/run/docker.sock
这个socket文件进行数据传输的,我们在terminal中执行Docker CLI操作时
docker container ls --all
docker info
实际上等同于执行下列命令(完整的Docker engine API可以查阅官网):
curl --unix-socket /var/run/docker.sock 'http://localhost/containers/json?all=true'
curl --unix-socket /var/run/docker.sock http://localhost/info
我们可以稍微改动一下我们的echo server代码来使用Unix sockets, 实现的效果和先前采用internet sockets时是一致的。不过要注意,socket API的system calls会自动为我们创建相应的socket文件,但却不会在事后清理掉,需要我们手动清理。下列是三种编程语言对应的不同类型sockets实现方法:
C:
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
int server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/server.sock");
unlink("/tmp/server.sock"); // make sure the file doesn't already exist
bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
Python:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 9000))
import os
SOCK_PATH = "/tmp/server.sock"
if os.path.exists(SOCK_PATH):os.remove(SOCK_PATH)s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(SOCK_PATH)
Go:
listener, _ := net.Listen("tcp", "127.0.0.1:9000")
sockPath := "/tmp/server.sock"
_ = os.Remove(sockPath)
listener, _ := net.Listen("unix", sockPath)
一点小知识:像/tmp/server.sock
这样的socket文件和常规文件不同——虽然本质上而言所有文件都是0和1构成的bytes, 但是我们如何与各种文件进行交互取决于它们的具体类型——它本身不存储任何数据,它只是一个传输数据的媒介,因此我们无法“打开”阅读这个文件,常规的vim /tmp/server.sock
操作在此时也没有意义。
除了上述两类socket (AF_INET
, AF_UNIX
)外,其实还有一些十分罕见的类型,但我们大概很少有机会能够遇见:
AF_NETLINK
(Linux kernel IPC)AF_PACKET
(used in packet sniffers like tcpdump)AF_CAN
(Controller Area Network, used in automotive systems)AF_BLUETOOTH
,AF_VSOCK
(for VM-host communication)
所有这些 AF_*
变量统称为address family, 也就是socket system call中的第一个参数 domain
int socket(int domain, int type, int protocol);
完整的address family列表可以查看Linux manual.
RPC
Socket programming遵循着「client索取数据,server满足提供数据」的设计思路,是现代网络的重要构成,但是如果只依靠它来支撑起整个网络世界未免有些简陋。我们的echo server大概是世界上最简陋的服务器之一,它只能提供「返还用户的输入值」这一个信息服务。而在现实世界中,绝大多数的server都包含着丰富的服务和功能,我们作为client试图去获取这些服务时,需要告诉server我们当前正在访问什么service, 如果采用原始的socket programming进行运作的话,每个service都需要和某个特定的socket进行绑定。
以小红书为例,假设要把访问推荐主页功能绑定在port 9000, 打开帖子功能在 port 9002, 添加评论在9003, 删除评论在9004, 点赞在9005, 收藏在9006…
It just doesn’t work.
现实中的做法是把小红书的server放在单一port (用HTTPS协议的话是port 443)上运行(严格来说应该是面向大众的port, 公司内部还有许多运行在其他port上的server来支撑整套庞大的服务系统运行)。
因此,我们需要设计一套能够让client告诉server “我想要访问这个功能”的方案,而RPC (Remote Procedure Call) 便是众多方案中的一种。现在,我们来拓展一下我们简陋的echo server, 给它增添一个新功能 ToUpper
, 可以把client发送来的字符串转成大写字符之后再返还回去。
借助RPC我们可以告诉server我们想要使用 Echo
和 ToUpper
这两个service中的哪一个,让我们用下列的Go实现来方便理解。为了尽可能地让代码简洁,我删掉了error handling的部分。
Go server:
package mainimport ("fmt""net""net/rpc""strings"
)// Request and Response types
type Request struct {Param string
}type Response struct {Result string
}// StringService with exported methods for RPC
type StringService struct{}func (s *StringService) ToUpper(req Request, res *Response) error {res.Result = strings.ToUpper(req.Param)return nil
}func (s *StringService) Echo(req Request, res *Response) error {res.Result = req.Paramreturn nil
}func main() {rpc.Register(&StringService{})listener, err := net.Listen("tcp", ":9000")fmt.Println("RPC server listening on port 9000")for {conn, _ := listener.Accept()go rpc.ServeConn(conn)}
}
Go client:
package mainimport ("fmt""net/rpc"
)func main() {client, _ := rpc.Dial("tcp", "localhost:9000")defer client.Close()testMethods := []string{"StringService.ToUpper", "StringService.Echo"}testString := "Don't worry, be happy"for _, method := range testMethods {req := Request{Param: testString}var res Responseclient.Call(method, req, &res)fmt.Printf("Method: %s → Result: %s\n", method, res.Result)}
}
在server这台计算机上运行这段Go程序,其中变量 StringService
处在内存中,当client发送信息 "StringService.ToUpper"
给server后,server会在它的这段Go程序里调用 StringService
这个对象的 ToUppe
函数,得到的结果暂存在内存中,server随后将结果发送给client, 由此实现了一段完整的通信服务。
Client通知server去执行某个对象的函数,就如同那个对象就在client的本地内存中一般可以“支配”,但实际上那个对象位于遥远的server之中,client也只能遵循双方约定好的接口来调用那个对象,而不能为所欲为——既不知道它内部有哪些fields/ states/ methods, 也不知道它们具体是怎么实现的。
以下便是来自维基百科的对于RPC的完整定义:
In distributed computing, a remote procedure call (RPC) is when a computer program causes a procedure (subroutine) to execute in a different address space (commonly on another computer on a shared computer network), which is written as if it were a normal (local) procedure call, without the programmer explicitly writing the details for the remote interaction.
回顾我们上面的server代码,信息量最大的一行代码是
rpc.Register(&StringService{})
所谓的register究竟是什么神奇的操作?
简单来说便是server在内存中维护着一个mapping/ look-up table, 能够按照对象函数的名字来定位目标。于是在收到client的消息时,server可以执行相应的操作(只要它能够找到相应的函数)。
{"StringService.ToUpper" -> StringService.ToUpper,"StringService.Echo" -> StringService.Echo
}
手写RPC
我们可以通过自己动手写一个迷你版本的RPC来加深理解。
server:
package mainimport ("bufio""encoding/json""fmt""net""reflect""strings"
)type Request struct {Method string `json:"method"`Param string `json:"param"`
}type Response struct {Result string `json:"result"`Error string `json:"error,omitempty"`
}// ========== Service Implementations ==========type StringService struct{}func (s *StringService) ToUpper(inputString string) string {return strings.ToUpper(inputString)
}func (s *StringService) Echo(inputString string) string {return inputString
}// ========== Dynamic Dispatcher ==========
var serviceRegistry = map[string]interface{}{"StringService": &StringService{},
}func handleConnection(conn net.Conn) {defer conn.Close()scanner := bufio.NewScanner(conn)encoder := json.NewEncoder(conn)for scanner.Scan() {line := scanner.Text()var req Requestif err := json.Unmarshal([]byte(line), &req); err != nil {json.NewEncoder(conn).Encode(Response{Error: "invalid request"})continue}// Split method name into "Service.Method"parts := strings.Split(req.Method, ".")if len(parts) != 2 {encoder.Encode(Response{Error: "invalid method format"})continue}serviceName, methodName := parts[0], parts[1]service, ok := serviceRegistry[serviceName]if !ok {encoder.Encode(Response{Error: "unknown service"})continue}method := reflect.ValueOf(service).MethodByName(methodName)if !method.IsValid() {encoder.Encode(Response{Error: "unknown method"})continue}args := []reflect.Value{reflect.ValueOf(req.Param)}results := method.Call(args)res := Response{Result: results[0].Interface().(string)}encoder.Encode(res)}
}func main() {listener, _ := net.Listen("tcp", ":9000")fmt.Println("RPC server listening on port 9000")for {conn, _ := listener.Accept()remoteAddr := conn.RemoteAddr()fmt.Println("Connected with remote: ", remoteAddr)go handleConnection(conn)}
}
client:
package mainimport ("bufio""encoding/json""fmt""net"
)func main() {conn, _ := net.Dial("tcp", "localhost:9000")defer conn.Close()testMethods := []string{"StringService.ToUpper", "StringService.Echo"}testString := "Don't worry, be happy"scanner := bufio.NewScanner(conn)for _, method := range testMethods {req := Request{Method: method, Param: testString}data, _ := json.Marshal(req)conn.Write(data)conn.Write([]byte("\n")) // newline is needed for Scanner on serverif scanner.Scan() {var res Responsejson.Unmarshal(scanner.Bytes(), &res)if res.Error != "" {fmt.Println("Error:", res.Error)} else {fmt.Println("Result:", res.Result)}}}
}
Server中的变量 serviceRegistry
便是我们刚才说所说的那个mapping, 它是RPC实现的核心之一,Go标准库 net/rpc
对此的实现要漂亮得多:
// Server represents an RPC Server.
type Server struct {serviceMap sync.Map // map[string]*servicereqLock sync.Mutex // protects freeReqfreeReq *RequestrespLock sync.Mutex // protects freeRespfreeResp *Response
}
当然啦,这对于业界的实际应用还是远远不够的。等到我对于Google开源的gRPC有足够深的理解后再来写文章吧~
Feature | Used in |
---|---|
Protobuf-based serialization | gRPC |
TLS + authentication | gRPC, Thrift |
Streaming APIs | gRPC |
Bidirectional channels | gRPC, WebSocket-based RPC |
Retry policies | gRPC |
Load balancing | gRPC, Dubbo |
Tracing/metrics | gRPC, Zipkin, OpenTelemetry |