跳到主要内容
版本:master

网络套接字(Socket)

1. 概述

网络套接字(Socket)是网络编程的核心,是实现不同主机间进程通信的端点。GS 语言提供了强大的 socket类型,该类型派生自 handle,封装了底层操作系统的 Socket 接口,并完美结合了协程机制,使得网络通信编程变得简单高效。

本文档是 GS 网络套接字编程的实用指南,主要内容包括:

  • Socket 生命周期管理:创建、连接、监听、关闭。
  • 数据收发:面向连接(TCP)与无连接(UDP)的数据传输。
  • 多路复用:使用 select模型管理多个 Socket。
  • 协程集成:如何利用协程构建高性能的并发网络服务器。

阅读对象:已掌握 GS 基础语法和协程概念,需要开发网络应用的开发者。

阅读收获:能够独立使用 GS Socket API 创建客户端和服务端程序,理解如何利用协程处理高并发网络连接。

2. 核心概念

GS 中的 socket对象代表一个网络通信通道。其核心工作模式分为两种:

  • 面向连接(TCP):提供可靠、有序、基于字节流的通信。需先建立连接,如打电话。
  • 无连接(UDP):提供不可靠、无序的数据报通信。无需建立连接,如寄明信片。

GS 的 Socket 操作是协程友好的。当一个协程执行如 recv这样的阻塞操作时,会自动挂起并让出 CPU,直到数据到达或超时,从而高效地支持大量并发连接。

3. TCP 套接字编程

TCP 通信遵循典型的客户端/服务器模型。

3.1 服务端

服务端负责监听特定端口,等待客户端连接。具体 socket 流程为创建、绑定、监听、接受连接。

基本流程:

  1. socket.create(): 创建 Socket。
  2. .bind(): 绑定本地 IP 和端口。
  3. .listen(): 开始监听。
  4. .accept(): 接受客户端连接。

一个简单的 Echo 服务器示例

#pragma parallel
import gs.util.socket;
readonly int PORT := 9090;
void handle_client(socket client_sock)
{
writeln("Client connected. Starting echo...");
int total_bytes = 0;

while (true)
{
// 接收客户端数据
let int recv_len, buffer data = client_sock.recv(1024);

if (recv_len <= 0)
{
writeln("Client disconnected or error.");
break;
}

total_bytes += recv_len;
printf("Received %d bytes, echoing back...\n", recv_len);

// 将收到的数据原样发回(Echo)
int sent_len = client_sock.send((string)data);
if (sent_len <= 0)
{
writeln("Send failed.");
break;
}
}

client_sock.close();
printf("Connection closed. Total echoed: %d bytes.\n", total_bytes);
}

void start_tcp_server()
{
// 1. 创建 TCP Socket
socket server_sock = socket.create(SockDomain.AF_INET, SockType.SOCK_STREAM);

// 2. 设置地址重用选项,避免"Address already in use"错误
server_sock.set_opt(SockOptLevel.SOCKET, SockOpt.REUSEADDR, 1);

// 3. 绑定到本地所有IP地址的指定端口
if (server_sock.bind(SockParam.INADDR_ANY, PORT) != 0)
{
writeln("Bind failed!");
return;
}

// 4. 开始监听,允许最多10个连接排队
if (server_sock.listen(10) != 0)
{
writeln("Listen failed!");
return;
}

writeln("Echo server is listening on port ", PORT, "...");

// 5. 循环接受客户端连接
while (true)
{
let socket client_sock, mixed client_ip = server_sock.accept();
if (client_sock)
{
printf("Accepted connection from %O\n", client_ip);
// 为每个客户端创建一个新的协程来处理
coroutine.create_with_domain(0, domain.create(), (:handle_client, client_sock:));
}
else
writeln("Accept failed.");
}
server_sock.close();
}

// 启动服务器(在后台协程)
coroutine.create_with_domain(0, domain.create(), (:start_tcp_server:));
writeln("Server started in background. Please run echo client to test server.");

示例3-1:简单的回声服务端示例

示例解析:

  • 核心流程:示例完整演示了 TCP 服务端的标准流程:创建 Socket → 设置选项 → 绑定端口 → 开始监听 → 循环接受连接。
  • 并发处理:关键点在于使用 coroutine.create_with_domain为每个接受的客户端连接创建独立的协程来处理(handle_client函数),这使得服务器能够同时服务多个客户端。
  • 数据收发:在 handle_client函数中,使用 recv接收数据,使用 send发送数据,实现 Echo 功能。
  • 连接管理:通过检查 recv返回值(≤0)来判断连接是否关闭,并适时关闭 Socket 释放资源。

3.2 客户端

客户端主动向服务器发起连接。具体流程为创建、连接、收发数据。

一个简单的Echo 客户端示例

import gs.util.socket;
const string SERVER_IP = "127.0.0.1";
const int SERVER_PORT = 9090;

void start_tcp_client()
{
// 1. 创建 TCP Socket
socket client_sock = socket.create(SockDomain.AF_INET, SockType.SOCK_STREAM);

// 2. 连接服务器
if (client_sock.connect(SERVER_IP, SERVER_PORT) != 0)
{
writeln("Connect to server failed!");
return;
}

writeln("Connected to echo server.");

// 3. 准备测试数据
string test_message = "Hello, GS Socket!";
int message_count = 3;

for (int i = 1 upto message_count)
{
// 4. 发送数据
printf("Sending message %d: %s\n", i, test_message);
int sent = client_sock.send(test_message + "\n");

if (sent <= 0)
{
writeln("Send failed.");
break;
}

// 5. 接收服务器的回显数据
let int recv_len, buffer echo_data = client_sock.recv(1024);
if (recv_len <= 0)
{
writeln("Receive failed or connection closed.");
break;
}

printf("Received echo: %d %s", recv_len, (string)echo_data);

// 短暂延迟,模拟实际间隔
coroutine.sleep(1); // 延迟1秒
}

// 6. 关闭连接
client_sock.close();
writeln("Disconnected from server.");
}

// 运行客户端
start_tcp_client();

示例3-2:简单的回声客户端示例

示例解析:

  • 核心流程:演示了 TCP 客户端的标准流程:创建 Socket → 连接服务器 → 收发数据 → 关闭连接。
  • 错误处理:检查 connectsendrecv的返回值是良好的编程习惯,可以及时发现连接或通信问题。
  • 数据转换recv返回的是 buffer类型,使用强制类型转换将其转换为字符串以便显示。在实际应用中,可能需要根据协议进行更复杂的数据解析。
  • 循环交互:通过循环多次发送和接收,模拟了持续的客户端-服务器交互。coroutine.sleep(1)用于在每次交互间加入延迟,使输出更易观察。

4. UDP 套接字编程

UDP 无需建立连接,直接向目标地址发送数据报。

4.1 服务端

#pragma parallel
import gs.util.socket;
const int UDP_PORT = 9091;

void start_udp_server()
{
// 1. 创建 UDP Socket
socket udp_sock = socket.create(SockDomain.AF_INET, SockType.SOCK_DGRAM);

// 2. 绑定到端口
if (udp_sock.bind(SockParam.INADDR_ANY, UDP_PORT) != 0)
{
writeln("UDP Bind failed!");
return;
}

writeln("UDP server is listening on port ", UDP_PORT, "...");

// 3. 循环接收数据报
while (true)
{
// 使用 recv_from 获取数据及发送方地址
let int recv_len, buffer data, sockaddr client_addr = udp_sock.recv_from(1024);

if (recv_len > 0)
{
string message = (string)data;
printf("Received from %O - %s\n", client_addr, message);

// 构建回复
string reply = "UDP Echo: " + message;

// 使用 send_to 回复到原发送方
udp_sock.send_to(reply, client_addr);
}
}

udp_sock.close();
}

// 启动 UDP 服务器
coroutine.create_with_domain(0, domain.create(), (:start_udp_server:));

示例4-1:upd 服务器示例

示例解析:

  • 无连接特性:UDP 服务端创建 Socket 后直接 bind到端口即可开始接收数据,没有 listenaccept步骤。
  • 关键函数recv_from是 UDP 编程的核心,它不仅能获取数据,还能同时得到数据发送方的地址(IP 和端口),这是进行回复的依据。
  • 请求-响应模式:示例展示了典型的 UDP 交互:接收请求 → 处理 → 使用 send_to指定目标地址发送响应。每个数据报都是独立的。
  • 阻塞点recv_from会阻塞直到有数据报到达。由于 UDP 无连接,一个服务器 Socket 可以交替与多个不同的客户端通信。

4.2 客户端

#pragma parallel
import gs.util.socket;
const string UDP_SERVER_IP = "127.0.0.1";
const int UDP_SERVER_PORT = 9091;

void start_udp_client()
{
// 1. 创建 UDP Socket(无需连接)
socket udp_sock = socket.create(SockDomain.AF_INET, SockType.SOCK_DGRAM);

string message = "Hello UDP!";

// 2. 直接发送数据报到目标地址
printf("Sending UDP message: %s\n", message);
int sent = udp_sock.send_to(message, UDP_SERVER_IP, UDP_SERVER_PORT);

if (sent > 0)
{
// 3. 等待接收回复(可选)
let int recv_len, buffer reply = udp_sock.recv_from(1024);

if (recv_len > 0)
{
printf("Received UDP reply: %s\n", (string)reply);
}
}

udp_sock.close();
}

// 运行 UDP 客户端
start_udp_client();

示例4-2:udp客户端示例

示例解析:

  • 直接发送:UDP 客户端在创建 Socket 后,无需 connect,直接使用 send_to指定服务器地址和端口即可发送数据。
  • 可选响应:客户端在发送后也可以调用 recv_from来等待服务器的回复,但这依赖于服务器设计。UDP 不保证对方一定会收到或回复。
  • 资源管理:即使是无连接的 UDP,在通信结束后关闭 Socket 也是一个好习惯。

5. 多路复用与 Select 模型

当需要同时管理多个 Socket(如同时处理监听和多个连接)时,可以使用 socket.select进行多路复用。

示例:使用 Select 处理多个客户端

socket.gs
#pragma parallel
import gs.util.socket;
const int SELECT_PORT = 9092;

void start_select_server()
{
socket server_sock = socket.create(SockDomain.AF_INET, SockType.SOCK_STREAM);
server_sock.set_opt(SockOptLevel.SOCKET, SockOpt.REUSEADDR, 1);
server_sock.bind(SockParam.INADDR_ANY, SELECT_PORT);
server_sock.listen(10);

writeln("Select server listening on port ", SELECT_PORT);

array read_fds = [server_sock]; // 需要监视读事件的Socket列表
array write_fds = []; // 需要监视写事件的Socket列表(本例未使用)
array except_fds = []; // 需要监视异常事件的Socket列表(本例未使用)

while (true)
{
// 复制fd列表,因为select会修改它
array read_ready = read_fds.dup();

// 等待事件发生,超时设为5秒
let int ready_count, array ready_fds = socket.select(read_ready, write_fds, except_fds, 5);
if (ready_count <= 0)
{
// 超时,无事件发生
writeln("Select timeout 5 seconds, no events.");
continue;
}

// 检查每个就绪的Socket
for(socket sock:ready_fds)
{
if (sock == server_sock)
{
// 监听Socket就绪,表示有新连接
let socket new_client, sockaddr client_addr = server_sock.accept();
if (new_client)
{
printf("New client via select: %O\n", client_addr);
read_fds.push_back(new_client); // 将新客户端加入监视列表
}
}
else
{
// 客户端Socket就绪,表示有数据可读
let int recv_len, buffer data = sock.recv(1024);

if (recv_len <= 0)
{
// 连接关闭或错误
printf("Client disconnected.\n");
sock.close();
read_fds.delete(sock); // 从监视列表中移除
}
else
{
// 处理数据
string message = (string)data;
printf("Received from client: %s", message);

// 简单回复
sock.send("Server received: " + message);
}
}
}
}

server_sock.close();
}

// 启动Select服务器
coroutine.create_with_domain(0, domain.create(), (:start_select_server:));

示例5-1:多路复用示例

示例解析:

  • 解决什么问题select模型允许单个线程/协程同时监视多个 Socket 的可读、可写或异常状态,避免为每个连接创建单独的协程,适用于连接数非常多或资源受限的场景。
  • 工作流程初始化监视列表:开始时,列表只包含监听 Socket (server_sock)。调用 select:等待列表中的 Socket 发生事件(本例只监视读事件)。调用会阻塞直到有事件发生或超时。处理就绪 Socket:如果是监听 Socket 就绪,说明有新连接,调用 accept接受,并将新连接的 Socket 加入监视列表。如果是客户端 Socket 就绪,说明有数据可读,调用 recv接收并处理。如果连接关闭,则从列表中移除该 Socket。
  • 关键点select会修改传入的数组,所以需要传入副本 (read_ready = read_fds.copy())。需要动态管理监视列表(read_fds.push(new_client)read_fds.remove(sock))。超时参数(本例为5秒)可以防止 select无限期阻塞,允许程序定期执行其他任务(如检查退出条件)。

7. 总结

本文档系统介绍了 GS 语言中网络套接字编程的核心功能,主要内容包括:

核心通信模式

  • 掌握了面向连接的 TCP 套接字编程,包括服务端的创建-绑定-监听-接受标准流程和客户端的连接-收发数据流程
  • 理解了无连接的 UDP 套接字编程,使用 send_torecv_from进行数据报通信

高级编程特性

  • 学会了利用 GS 的协程机制轻松构建高并发网络服务器,每个连接在独立协程中处理
  • 掌握了 select多路复用技术,用于管理大量并发连接的场景

实践应用能力

  • 通过完整的 Echo 服务器示例,理解了网络编程的实际应用
  • 通过文档的性能测试示例,学习了如何协调多个协程完成复杂的网络通信任务

网络编程是分布式系统开发的基础,建议从简单的客户端/服务器程序开始实践,逐步掌握更复杂的网络应用开发。