网络编程

Socket

如果我们要将数据从电脑 A 的某个进程发到电脑 B 的某个进程,如果需要确保数据能够发送给对方,那就选可靠的 TCP 协议,否则可以采用 UDP 协议。

那这时候就需要用 socket 进行编程,第一步就是创建一个关于 TCP 的 socket。

下文皆以 TCP 为例

1
2
3
// SOCK_STREAM:TCP 流套接字
// SOCK_DGRAM:UDP 数据报套接字
int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sock_fd 相当于文件句柄,客户端和服务端都需要各自创建 fd

  • 对于服务端:就可以根据 fd 依次执行 bind()listen()accept() 方法,然后坐等客户端的连接请求
  • 对于客户端:根据 fd 来执行 connect() 向服务端发起建立连接的请求,此时就会发生 TCP 三次握手

img

以下是 TCP 三次握手的示意图:

img

连接建立完成后,客户端可以执行 send() 发送消息,服务端可以执行 recv() 接收消息;反过来,服务端也可以执行 send(),客户端执行 recv()

相关函数:

1
2
3
4
5
6
7
int socket(int __domain, int __type, int __protocol)
int bind(int __fd, const struct sockaddr *__addr, socklen_t __len)
int listen(int __fd, int __n)
int connect(int __fd, const struct sockaddr *__addr, socklen_t __len)
int accept(int __fd, struct sockaddr *__restrict__ __addr, socklen_t *__restrict__ __addr_len)
ssize_t recv(int __fd, void *__buf, size_t __n, int __flags)
ssize_t send(int __fd, const void *__buf, size_t __n, int __flags)

服务端

可以使用 socket() 系统调用创建套接字,它在 <sys/socket.h> 中定义。

Definition

1
int socket(int __domain, int __type, int __protocol)
  • __domain
    • AF_INET:IPv4
    • AF_INET6:IPv6
  • __type
    • SOCK_STREAM:TCP 流套接字
    • SOCK_DGRAM:UDP 数据报套接字
  • __protocol:指定协议,当 __protocol 为 0 时,会自动选择 type 类型对应的默认协议。
    • IPPROTO_TCP
    • IPPTOTO_UDP
    • IPPROTO_SCTP
    • IPPROTO_TIPC

Usage

1
2
3
4
5
6
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
// 定义服务器地址
sockaddr_in serverAddress;
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(8080);
serverAddress.sin_addr.s_addr = INADDR_ANY;
  • sockaddr_in:用于存储套接字地址的数据类型
  • htons:该函数用于将 unsigned int 从机器字节序转换为网络字节序
  • INADDR_ANY:当我们不想将套接字绑定到任何特定网卡 IP,而是让它监听所有网卡(所有可用 IP)时使用
1
bind(serverSocket, (struct sockaddr*)&serverAddress, sizeof(serverAddress));
  • 调用 bind() 绑定套接字
1
listen(serverSocket, 5);
  • 然后监听 serverSocket 引用的套接字
1
int clientSocket = accept(serverSocket, nullptr, nullptr);
  • accept() 调用用于接受应用程序正在监听的套接字上收到的连接请求
1
2
3
char buffer[1024] = {0};
recv(clientSocket, buffer, sizeof(buffer), 0);
cout << "Message from client: " << buffer << endl;
  • 然后开始从客户端接收数据,我们可以指定所需的缓冲区大小,以便有足够的空间接收客户端发送的数据
1
close(serverSocket);
  • 最后使用 close() 关闭套接字

server.cpp 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// C++ program to show the example of server application in
// socket programming
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

using namespace std;

int main()
{
// creating socket
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);

// specifying the address
sockaddr_in serverAddress;
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(8080);
serverAddress.sin_addr.s_addr = INADDR_ANY;

// binding socket.
bind(serverSocket, (struct sockaddr*)&serverAddress,
sizeof(serverAddress));

// listening to the assigned socket
listen(serverSocket, 5);

// accepting connection request
int clientSocket = accept(serverSocket, nullptr, nullptr);

// recieving data
char buffer[1024] = { 0 };
recv(clientSocket, buffer, sizeof(buffer), 0);
cout << "Message from client: " << buffer << endl;

// closing the socket
close(serverSocket);

return 0;
}

更完整的写法:server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
// int socket(int __domain, int __type, int __protocol)
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
server_addr.sin_port = htons(2000); // system used: 0 ~ 1023

if (bind(sock_fd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr)) == -1) {
printf("bind failed: %s\n", strerror(errno));
}

// int listen(int __fd, int __n): 后者为等待队列的长度
listen(sock_fd, 10);
printf("listen finished: %d\n", sock_fd); // 3
system("netstat -ano | grep 2000");
// getchar(); // 方便查看 netstat -ano | grep 2000: tcp 0 0 0.0.0.0:2000 0.0.0.0:* LISTEN off (0.00/0/0)

struct sockaddr_in client_addr;
// int accept(int __fd, struct sockaddr *__restrict__ __addr, socklen_t *__restrict__ __addr_len)
socklen_t len = sizeof(client_addr);
int client_fd = accept(sock_fd, (struct sockaddr*)&client_addr, &len);
printf("accept finished\n");
system("netstat -ano | grep 2000");

char buffer[1024] = {0};
// ssize_t recv(int __fd, void *__buf, size_t __n, int __flags)
int count = recv(client_fd, buffer, sizeof(buffer), 0);
printf("RECV: %s\n", buffer);

// ssize_t send(int __fd, const void *__buf, size_t __n, int __flags)
count = send(client_fd, buffer, count, 0);
printf("SEND: %d\n", count);

// getchar();
while (1);

return 0;
}

客户端

与服务器类似,我们也需要创建一个套接字并指定地址。不过,我们不会接受请求,而是在能够使用 connect() 调用发送数据时,发送连接请求。然后我们使用 send() 函数发送数据。所有操作完成后,我们使用 close() 函数关闭连接。

client.cpp 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// C++ program to illustrate the client application in the
// socket programming
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

int main()
{
// creating socket
int clientSocket = socket(AF_INET, SOCK_STREAM, 0);

// specifying address
sockaddr_in serverAddress;
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(8080);
serverAddress.sin_addr.s_addr = INADDR_ANY;

// sending connection request
connect(clientSocket, (struct sockaddr*)&serverAddress,
sizeof(serverAddress));

// sending data
const char* message = "Hello, server!";
send(clientSocket, message, strlen(message), 0);

// closing socket
close(clientSocket);

return 0;
}

更完整的写法:client.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <netinet/in.h>
#include <stdio.h>
#include <sys/socket.h>

int main() {
unsigned short port = 2000;
// char *server_ip = "10.26.57.8"; // 应该发送到对应IP的网卡地址: 10.26.57.3
// char *server_ip = "localhost"; // ✅ 两个程序在同一个服务器上跑就可
char *server_ip = "10.26.57.3"; // ✅ 网卡地址能接收到

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket error");
exit(-1);
}

struct sockaddr_in server_addr;
// bzero <==> memset(&server_addr, 0, sizeof(server_addr));
bzero(&server_addr, sizeof(server_addr)); // 初始化服务器地址
// AF_INET: IPv4
// AF_INET6: IPv6
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(port);
// inet_pton() 是一个通用的地址转换函数,不知道你打算转换成 IPv4 还是 IPv6,必须由你告诉它目标地址族
// AF_INET: IPv4
// AF_INET6: IPv6
// 客户端绑定具体服务端IP必须使用该方法
inet_pton(AF_INET, server_ip, &server_addr.sin_addr.s_addr);
system("netstat -ano | grep 2000");

// int connect(int __fd, const struct sockaddr *__addr, socklen_t __len)
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connect error");
close(sockfd);
exit(-1);
}

system("netstat -ano | grep 2000");

char buffer[1024] = "Client INFO: test...";
// ssize_t send(int __fd, const void *__buf, size_t __n, int __flags)
int count = send(sockfd, buffer, sizeof(buffer), 0);
printf("SEND: %d\n", count);

while (1);

return 0;
}

源码分析

sockaddr

sockaddr 在头文件 <sys/socket.h> 中定义,sockaddr 的缺陷是 sa_data 把「目标地址」和「端口信息」混在一起了:

1
2
3
4
5
struct sockaddr
{
  unsigned short sa_family; // 2 字节,地址族,AF_xxx
  char sa_data[14]; // 14 字节,包含套接字中的目标地址和端口信息
};

sockaddr_in

sockaddr_in 在头文件 <netinet/in.h><arpa/inet.h> 中定义,该结构体解决了 sockaddr 的缺陷,把 portaddr 分开存储在两个变量中:

1
2
3
4
5
6
struct sockaddr_in {
sa_family_t sin_family; // 地址族,一般是 AF_INET(IPv4),也可以是 AF_INET6(IPv6)
in_port_t sin_port; // 端口号(使用 htons() 转换为网络字节序)
struct in_addr sin_addr; // IP 地址(结构体)
char sin_zero[8];// 填充字段,保持结构体大小与 sockaddr 一致
};
1
2
3
struct in_addr {
unsigned long s_addr; // 32 位 IPv4 地址打印的时候可以调用 inet_ntoa() 函数将其转换为 char* 类型
};

sockaddr 常用于 bindconnectrecvfromsendto 等函数的参数,指明地址信息,是一种通用的套接字地址。

sockaddr_ininternet 环境下套接字的地址形式。所以在网络编程中我们会对 sockaddr_in 结构体进行操作,使用 sockaddr_in 来建立所需的信息,最后使用类型转化 (struct sockaddr*) 即可。

一般先把 sockaddr_in 变量赋值后,强制类型转换后传入用 sockaddr 做参数的函数:

  • sockaddr_in 用于 socket 定义和赋值
  • sockaddr 用于函数参数

Usage:程序员不应该操作 sockaddr,而是使用 sockaddr_in 来表示地址,并强转为 (struct sockaddr *) 传入函数中:

1
2
3
4
5
// int accept(int __fd, struct sockaddr *__restrict__ __addr, socklen_t *__restrict__ __addr_len)
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);

int client_fd = accept(sock_fd, (struct sockaddr*)&client_addr, &len);
1
2
3
4
5
6
7
8
// int connect(int __fd, const struct sockaddr *__addr, socklen_t __len)
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr)); // 初始化服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(2000);
inet_pton(AF_INET, "10.26.57.3", &server_addr.sin_addr.s_addr);

connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)

讲一讲 I/O 多路复用|select、poll、epoll 的区别是什么?

I/O 多路复用是一种 I/O 的处理方式,指的是复用一个线程处理多个 socket 中的事件。能够复用资源,防止创建过多线程导致的上下文切换的开销。

img

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。

select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

select、poll

✅ select 图解

image-20250501021504644

✅ poll 图解

image-20250501021646782

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 $O(n)$,而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长

epoll

Linux 2.6 版本诞生了 epoll 模型,彻底解决了 select/poll 性能不足的问题

✅ epoll 图解

image-20250501023238509

先复习下 epoll 的用法。如下的代码中,先用 epoll_create 创建一个 epoll 对象 epoll_fd,再通过 epoll_ctl 将需要监视的 socket 添加到 epoll_fd 中,最后调用 epoll_wait 等待数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);

// epoll_fd
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); // 将所有需要监听的 socket 添加到 epfd 中

while(1) {
int n = epoll_wait(...);
for (接收到数据的 socket) {
//处理
}
}

epoll 通过两个方面,很好解决了 select/poll 的问题:

  • 第一点,epoll 在内核里使用「红黑树」来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 $O(logn)$。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • 第二点,epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,内核通过回调函数将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

img

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题(服务器同时处理 10,000个 客户端连接的挑战)的利器。

参考链接


本站总访问量
本站共发表 94 篇文章 · 总计 325.9k 字
载入天数...载入时分秒...