- Socket 编程核心概念:在深入代码之前,先理解基本原理。
- Java Socket 编程:提供客户端和服务端的完整代码示例。
- C 语言 Socket 编程:同样提供客户端和服务端的完整代码示例(在 Linux/Unix 环境下)。
- 对比与总结:比较 Java 和 C 在 Socket 编程上的异同。
- 进阶话题:多线程、NIO、阻塞与非阻塞等。
Socket 编程核心概念
可以把 Socket 想象成一个“网络插座”,你把一个插头(客户端)插进插座(服务端),两者之间就建立了一条可以双向通信的管道。
关键概念
- IP 地址:网络中设备的唯一标识,就像你的家庭住址。
- 端口号:设备上应用程序的唯一标识,就像你家楼上的几零几房间,一个 IP 地址可以有多个端口,每个端口对应一个服务。
- 协议:通信的规则,我们主要关注两种:
- TCP (Transmission Control Protocol):面向连接的、可靠的协议,通信前必须先建立连接(三次握手),数据传输有确认、重传和排序机制,确保数据无差错、不丢失、不重复且按序到达,就像打电话,必须先接通才能说话。
- UDP (User Datagram Protocol):无连接的、不可靠的协议,发送数据前不需要建立连接,直接把数据包(Datagram)发出去,不保证对方一定能收到,也不保证顺序,就像寄明信片,寄出去就完事了,对方可能收不到,也可能乱序。
- Socket:是操作系统提供的一种 API,它封装了底层的 TCP/IP 协议,应用程序通过调用 Socket API,就可以方便地进行网络通信。
通信流程(以 TCP 为例)
服务端:
- 创建一个 Socket (
socket())。 - 绑定 Socket 到一个 IP 地址和端口号 (
bind()),这样客户端才能找到它。 - 监听来自客户端的连接请求 (
listen())。 - 接受客户端的连接请求,并创建一个新的 Socket 与客户端通信 (
accept()),这个accept()是阻塞的,它会一直等待直到有客户端连接。 - 通过新创建的 Socket 与客户端进行收发数据 (
send()/recv()或read()/write())。 - 关闭 Socket (
close())。
客户端:
- 创建一个 Socket (
socket())。 - 连接到服务器的 IP 地址和端口号 (
connect()),这个connect()也是阻塞的,直到连接成功或超时。 - 连接成功后,通过这个 Socket 与服务端进行收发数据 (
send()/recv()或read()/write())。 - 关闭 Socket (
close())。
Java Socket 编程
Java 的 Socket API 封装得非常好,面向对象,使用起来非常简洁。
服务端代码 (JavaServer.java)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class JavaServer {
public static void main(String[] args) {
int port = 8888; // 定义端口号
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("服务器启动,正在监听端口 " + port + "...");
// accept() 方法会阻塞,直到有客户端连接
Socket clientSocket = serverSocket.accept();
System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
// 获取输入流,用于读取客户端发送的数据
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
// 获取输出流,用于向客户端发送数据
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String inputLine;
// 循环读取客户端发送的数据
while ((inputLine = in.readLine()) != null) {
System.out.println("收到客户端消息: " + inputLine);
// 如果客户端发送 "bye",则结束通信
if ("bye".equalsIgnoreCase(inputLine)) {
break;
}
// 将收到的消息转换成大写并发送回客户端
String response = inputLine.toUpperCase();
out.println(response);
}
System.out.println("客户端已断开连接。");
} catch (IOException e) {
System.err.println("服务器异常: " + e.getMessage());
e.printStackTrace();
}
}
}
客户端代码 (JavaClient.java)
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
public class JavaClient {
public static void main(String[] args) {
String hostname = "localhost"; // 服务器的IP地址,本地测试用localhost
int port = 8888; // 必须与服务端端口一致
try (Socket socket = new Socket(hostname, port)) {
System.out.println("已连接到服务器 " + hostname + ":" + port);
// 获取输入流,用于读取服务器发送的数据
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 获取输出流,用于向服务器发送数据
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
// 创建一个控制台输入读取器
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in));
String userInput;
// 循环读取用户在控制台的输入
while ((userInput = stdIn.readLine()) != null) {
// 将用户输入发送给服务器
out.println(userInput);
// 如果用户输入 "bye",则结束通信
if ("bye".equalsIgnoreCase(userInput)) {
break;
}
// 读取服务器返回的响应
String response = in.readLine();
System.out.println("服务器响应: " + response);
}
} catch (UnknownHostException e) {
System.err.println("不知道主机: " + hostname);
e.printStackTrace();
} catch (IOException e) {
System.err.println("I/O: " + e.getMessage());
e.printStackTrace();
}
}
}
如何运行:
- 先编译并运行
JavaServer。 - 然后在新的终端窗口编译并运行
JavaClient。 - 在客户端的控制台输入文字,按回车,你会看到服务器将其转换成大写并返回,输入
bye结束程序。
C 语言 Socket 编程
C 语言的 Socket API 是基于 BSD Socket 接口,更底层,需要手动处理很多细节,比如类型转换、字节序等。
服务端代码 (c_server.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 1. 创建 socket 文件描述符
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 设置 socket 选项,允许地址重用
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET; // IPv4
address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
address.sin_port = htons(PORT); // 将端口号从主机字节序转换到网络字节序
// 3. 绑定 socket 到地址和端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 4. 开始监听连接
if (listen(server_fd, 3) < 0) { // 3 是最大挂起连接数
perror("listen");
exit(EXIT_FAILURE);
}
printf("服务器启动,正在监听端口 %d...\n", PORT);
// 5. 接受一个新连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("客户端已连接: %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 与客户端通信
int valread;
while ((valread = read(new_socket, buffer, BUFFER_SIZE)) > 0) {
printf("收到客户端消息: %s\n", buffer);
// 如果客户端发送 "bye",则结束通信
if (strncmp(buffer, "bye", 3) == 0) {
break;
}
// 将收到的消息转换成大写并发送回客户端
for (int i = 0; i < valread; i++) {
if (buffer[i] >= 'a' && buffer[i] <= 'z') {
buffer[i] = buffer[i] - 32; // 转换成大写
}
}
send(new_socket, buffer, valread, 0);
memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区
}
// 7. 关闭 socket
close(new_socket);
close(server_fd);
return 0;
}
客户端代码 (c_client.c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE] = {0};
char message[BUFFER_SIZE] = {0};
// 1. 创建 socket 文件描述符
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("\n Socket creation error \n");
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将 IPv4 地址从文本转换成二进制形式
if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 2. 连接到服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
printf("\nConnection Failed \n");
return -1;
}
printf("已连接到服务器 127.0.0.1:%d\n", PORT);
// 3. 与服务器通信
while (1) {
printf("请输入消息 (输入 'bye' 退出): ");
fgets(message, BUFFER_SIZE, stdin); // 从控制台读取一行
// 发送消息给服务器
send(sock, message, strlen(message), 0);
// 如果用户输入 "bye",则结束通信
if (strncmp(message, "bye", 3) == 0) {
break;
}
// 读取服务器的响应
int valread = read(sock, buffer, BUFFER_SIZE);
buffer[valread] = '\0'; // 确保字符串正确终止
printf("服务器响应: %s\n", buffer);
memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区
}
// 4. 关闭 socket
close(sock);
return 0;
}
如何运行 (Linux/Unix/macOS):
- 你需要一个 C 编译器,
gcc。 - 分别编译两个文件:
gcc c_server.c -o c_server gcc c_client.c -o c_client
- 先运行服务端:
./c_server
- 在新的终端窗口运行客户端:
./c_client
- 在客户端输入文字,按回车,服务器会返回大写版本,输入
bye结束。
对比与总结
| 特性 | Java Socket | C Socket |
|---|---|---|
| API 风格 | 面向对象。Socket, ServerSocket, InputStream/OutputStream 等都是类。 |
面向过程/函数式,通过 socket(), bind(), connect() 等函数操作文件描述符。 |
| 易用性 | 高,API 设计清晰,自动处理了很多底层细节(如流包装、异常)。 | 低,需要手动管理内存、类型转换(字节序)、错误检查,代码更繁琐。 |
| 平台无关性 | 高。“一次编写,到处运行”,只要安装了 JVM,代码就能在任何系统上运行。 | 低,代码依赖于特定的操作系统 API(如 Windows 的 Winsock 和 Linux 的 BSD Socket),需要跨平台移植。 |
| 底层控制 | 低,隐藏了太多细节,不适合需要极致性能或特殊网络配置的场景。 | 高,可以精细控制 socket 的每一个选项,如阻塞/非阻塞模式、超时等。 |
| 错误处理 | 使用 try-catch 异常处理机制。 |
通过函数返回值(通常是 -1 表示错误)和 errno 全局变量来判断错误。 |
| 字节序 | Socket 类内部会自动处理字节序转换,开发者无需关心。 |
必须手动处理。htons() (host to network short), ntohs() (network to host short) 等函数是必需的。 |
- Java 更适合快速开发、企业级应用和跨平台场景,它将网络编程的复杂性封装在简洁的 API 之下,让你更专注于业务逻辑。
- C 更适合系统编程、嵌入式开发、需要高性能或底层网络控制(如实现自己的协议)的场景,它提供了最大的灵活性和控制力,但代价是更高的复杂度和更繁琐的代码。
进阶话题
阻塞 vs. 非阻塞
- 阻塞:默认模式,调用
accept()或read()等函数时,如果当前没有数据或连接,线程会暂停(阻塞),直到有事件发生,上面的例子都是阻塞模式的。 - 非阻塞:通过设置
O_NONBLOCK标志,这些函数会立即返回,如果没有数据,它们会返回一个错误码(如EWOULDBLOCK),而不是等待,这需要程序员自己不断地轮询检查,非常消耗 CPU。
I/O 多路复用
这是处理高并发连接的更高效的方式,它允许你同时监视多个文件描述符(Socket),一旦其中任何一个“就绪”(可以读或写),内核就会通知你。
- Java NIO (New I/O):
- Channel:代替传统的
Stream,是双向的。 - Buffer:所有数据的读写都通过
Buffer进行。 - Selector:核心组件,一个
Selector可以注册多个Channel,它会帮你检查哪些Channel已经就绪,从而实现单线程管理多个连接,大大提高了效率。
- Channel:代替传统的
- C 语言:
select():最古老的方式,有文件描述符数量限制(通常是 1024)。poll():改进了select(),没有数量限制,但效率依然不高。epoll()(Linux) /kqueue()(BSD/macOS):最高效的方式,没有数量限制,性能极佳,是构建高性能网络服务器的标准选择。
多线程
对于简单的阻塞式 Socket 服务器,一个 accept() 只能处理一个客户端,为了同时服务多个客户端,常见的方法是:
- 主线程负责
accept(),每当有新连接时,创建一个新线程来处理这个客户端的通信。 - 使用线程池来管理这些线程,避免频繁创建和销毁线程带来的开销。
这两种方法(多线程和 NIO)是构建高并发网络服务器的两种主要技术路线。
