1. socket
Socket 是一种用于网络通信的编程接口,它提供了一种类似于文件描述符的接口,允许不同计算机之间的进程进行通信。Socket 可以工作在多种协议上,最常用的是 TCP/IP 和 UDP/IP 协议
1.1 UDP
1.1.1 概念
UDP(用户数据报协议)是一种无连接的网络协议,它允许数据在不建立可靠连接的情况下快速传输。UDP 不保证数据的可靠性、顺序性或完整性,但它的速度比 TCP 快,适合对实时性要求较高的应用,例如视频流、语音通信、游戏等。
1.1.2 UDP 的特点
-
无连接:UDP 不需要建立连接,发送方可以直接发送数据报,接收方接收数据报。
-
不可靠:UDP 不保证数据报的传输顺序、完整性或可靠性。数据可能会丢失、重复或乱序到达。
-
轻量级:UDP 的头部只有8字节,比 TCP 的20-60字节的头部更轻量。
-
速度较快:由于没有连接建立和确认机制,UDP 的传输速度比 TCP 快。
1.1.3 UDP 数据报组成:
-
UDP 头部(8字节):
-
源端口(16位):发送方的端口号。
-
目的端口(16位):接收方的端口号。
-
长度(16位):UDP 数据报的总长度(包括头部和数据部分)。
-
校验和(16位):用于检测数据报在传输过程中是否出错。
-
-
数据部分:用户数据。
1.1.4 示例代码
服务器代码:UDP 服务器会监听特定端口,接收来自客户端的消息并打印出来
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int sockfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_addr_len = sizeof(client_addr);
char buffer[1024];
// 创建 UDP 套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "Error creating socket" << std::endl;
return 1;
}
// 配置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // 监听端口
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
// 绑定套接字到地址
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
std::cerr << "Error binding socket" << std::endl;
close(sockfd);
return 1;
}
std::cout << "UDP server is running and waiting for messages..." << std::endl;
while (true) {
// 接收客户端消息
int bytes_received = recvfrom(sockfd, buffer, sizeof(buffer), 0,
(struct sockaddr*)&client_addr, &client_addr_len);
if (bytes_received < 0) {
std::cerr << "Error receiving message" << std::endl;
continue;
}
buffer[bytes_received] = '\0'; // 确保字符串以 null 结尾
std::cout << "Received message from client: " << buffer << std::endl;
// 可选:向客户端发送响应
const char* response = "Message received";
sendto(sockfd, response, strlen(response), 0,
(struct sockaddr*)&client_addr, client_addr_len);
}
close(sockfd);
return 0;
}
客户端代码:UDP 客户端会向服务器发送消息,并接收服务器的响应。
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int sockfd;
struct sockaddr_in server_addr;
char buffer[1024];
// 创建 UDP 套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "Error creating socket" << std::endl;
return 1;
}
// 配置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // 服务器端口
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务器 IP 地址
std::cout << "Enter a message to send to the server: ";
std::cin.getline(buffer, sizeof(buffer));
// 发送消息到服务器
if (sendto(sockfd, buffer, strlen(buffer), 0,
(struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
std::cerr << "Error sending message" << std::endl;
close(sockfd);
return 1;
}
std::cout << "Waiting for server response..." << std::endl;
// 接收服务器响应
int bytes_received = recvfrom(sockfd, buffer, sizeof(buffer), 0,
NULL, NULL);
if (bytes_received < 0) {
std::cerr << "Error receiving response" << std::endl;
close(sockfd);
return 1;
}
buffer[bytes_received] = '\0'; // 确保字符串以 null 结尾
std::cout << "Server response: " << buffer << std::endl;
close(sockfd);
return 0;
}
基本逻辑
1.2 TCP
1.2.1 概念
TCP(传输控制协议)是一种面向连接的、可靠的网络协议,用于在不可靠的网络中提供可靠的字节流服务。TCP 通过建立连接、确认机制、重传机制等技术,确保数据的完整性和顺序性,适用于对数据可靠性要求较高的应用,如文件传输、网页浏览、邮件服务等。
1.2.2 TCP 的特点
-
面向连接:TCP 在数据传输之前需要建立连接,确保通信双方的准备就绪。
-
可靠传输:TCP 提供可靠的数据传输服务,通过确认机制、重传机制和滑动窗口协议来确保数据的完整性和顺序性。
-
字节流:TCP 将数据视为字节流,不保留数据的边界信息。
-
流量控制:TCP 使用滑动窗口协议来控制发送方的发送速率,防止接收方被淹没。
-
拥塞控制:TCP 通过拥塞控制算法(如慢启动、拥塞避免、快速重传等)来避免网络拥塞。
1.2.3 示例代码
服务端
#include <string.h>
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/ip.h> /* superset of previous */
#include <unistd.h>
int main() {
int tcp_socket;
int ret;
// 1. 申请一个TCP的socket
tcp_socket = socket(AF_INET, SOCK_STREAM, 0);
if (tcp_socket == -1) {
perror("socket");
return -1;
}
int opt = 1;
ret = setsockopt(tcp_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
if (ret < 0) {
perror("set sock opt");
return -1;
}
// 2. 作为服务器,应该在某一个端口上进行监测
struct sockaddr_in self;
memset(&self, 0, sizeof(self));
// 2.1 填充socket tcp/ip协议的地址
self.sin_family = AF_INET;
self.sin_port = htons(7788);
self.sin_addr.s_addr = htonl(INADDR_ANY); // 服务器是可以在任何机器上运行,监测当前系统的所有IP
ret = bind(tcp_socket, (const struct sockaddr *) &self, sizeof(self));
if (ret == -1) {
perror("bind");
return -1;
}
// 3. 将主动发送的socket变为被动监听的socket类型
listen(tcp_socket, 5);
printf("listen...\n");
// 4. 等待新的客户端发来消息
int new_fd;
char buf[1024];
ssize_t t;
while (1) {
// 4.1 使用accept等待listen fd是否有成功的描述
new_fd = accept(tcp_socket, NULL, NULL);
if (new_fd < 0) {
perror("accept");
break;
}
printf("New connection success!\n");
// 5. 服务器等待新的客户端发来消息(请求),服务器根据请求来对客户端进行响应
while (1) { // 客户端发来消息,直到客户端关闭,服务器才关闭
memset(buf, 0, sizeof(buf));
// 5.1 接收请求
t = recv(new_fd, buf, sizeof(buf), 0);
if (t <= 0) {
if (errno == 0) {
printf("remote client closed!\n");
break;
}
perror("recv");
break;
}
buf[t] = 0;
printf("receive : %s\n", buf);
// 5.2 发送响应
send(new_fd, "hello", 5, 0);
}
close(new_fd);
}
close(tcp_socket);
return 0;
}
客户端
#include <string.h>
#include <stdio.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/ip.h> /* superset of previous */
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
int main(int argc, char *argv[]) {
int tcp_socket;
struct sockaddr_in dest;
uint16_t port;
socklen_t dest_len = sizeof(dest);
int ret;
if (argc < 3) {
fprintf(stderr, "Usage: tcp_client ip port");
exit(-1);
}
port = atoi(argv[2]);
// 1. 申请一个TCP的socket
tcp_socket = socket(AF_INET, SOCK_STREAM, 0);
if (tcp_socket == -1) {
perror("socket");
return -1;
}
memset(&dest, 0, sizeof(dest));
dest.sin_family = AF_INET;
dest.sin_port = htons(port);
dest.sin_addr.s_addr = inet_addr(argv[1]); // inet_aton(argv[1], &dest.sin_addr);
// 2. 第一次握手
ret = connect(tcp_socket, (const struct sockaddr *)&dest, dest_len);
if (ret < 0) {
perror("connect");
exit(-1);
}
printf("connect success!\n");
// 3. 发起请求
char buf[1024];
memset(buf, 0, sizeof(buf));
printf("<input>:");
while (fgets(buf, sizeof(buf), stdin) != NULL) {
buf[strlen(buf) - 1] = 0;
if (strncmp(buf, "quit", 4) == 0) {
break;
}
ret = send(tcp_socket, buf, strlen(buf), 0);
if (ret == -1) {
perror("send to");
break;
}
printf("send %ld bytes success!\n", ret);
ret = recv(tcp_socket, buf, sizeof(buf), 0);
buf[ret] = 0;
printf("server: %s\n", buf);
printf("<input>:");
}
close(tcp_socket);
return 0;
}
一个进程默认能打开1024个文件描述符
在C++中,多路I/O复用是一种高效的I/O处理方式,允许程序同时监视多个文件描述符(如网络连接或文件),并等待它们中的任何一个变为可读或可写。以下是几种常见的多路I/O复用技术及其在C++中的实现方式;
2. 多路IO复用
2.1 select
2.1.1 概念
select
是一种多路I/O复用技术,用于同时监视多个文件描述符(如套接字、文件等),以确定它们是否准备好进行读、写或异常操作。
select
使用位域来表示文件描述符集合,通过位操作来快速检查和更新文件描述符的状态。内核只维护这个位域结构。
工作流程
select 的工作流程可以分为以下几个步骤:
初始化文件描述符集合:将需要监视的文件描述符添加到 fd_set 中。
调用 select:将文件描述符集合传递给 select 函数,同时指定最大文件描述符值(fdmax)和超时时间。
等待 I/O 事件:select 函数会阻塞,直到:
至少有一个文件描述符准备好进行 I/O 操作。
超时时间到达。
检查结果:select 返回后,程序可以检查哪些文件描述符已经准备好,然后进行相应的读写操作。
1.2 函数原型及参数说明
#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
nfds
表示要监视的最大文件描述符加1。例如,如果你的文件描述符范围是0到10,nfds 应该是11。
这个参数用于指定 select 需要检查的最大文件描述符范围。
readfds
指向一个 fd_set 类型的指针,表示需要监视的可读文件描述符集合。
如果不需要监视可读文件描述符,可以传入 NULL。
writefds
指向一个 fd_set 类型的指针,表示需要监视的可写文件描述符集合。
如果不需要监视可写文件描述符,可以传入 NULL。
exceptfds
指向一个 fd_set 类型的指针,表示需要监视的异常条件文件描述符集合。
如果不需要监视异常条件,可以传入 NULL。
timeout
指向一个 struct timeval 类型的指针,表示 select 的超时时间。
如果传入 NULL,select 将会阻塞,直到有文件描述符准备好。
如果传入一个时间值,select 将在指定时间内未检测到任何准备好状态时返回。
1.3 示例代码
#include <iostream>
#include <cstring>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int server_fd, client_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
fd_set readfds;
struct timeval tv;
// 创建 Socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 绑定地址和端口
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
std::cout << "Server is running on port 8080..." << std::endl;
// 初始化文件描述符集合
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
// 设置超时时间
tv.tv_sec = 5; // 5 秒超时
tv.tv_usec = 0;
int max_fd = server_fd;
while (true) {
fd_set tempfds = readfds; // 复制文件描述符集合
int activity = select(max_fd + 1, &tempfds, NULL, NULL, &tv);
if ((activity < 0) && (errno != EINTR)) {
std::cout << "Select error";
}
// 判断服务器的监听套接字是否有新的连接请求。
if (FD_ISSET(server_fd, &tempfds)) {
if ((client_fd = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
std::cout << "Client connected." << std::endl;
FD_SET(client_fd, &readfds); // 将客户端文件描述符加入集合
if (client_fd > max_fd) {
max_fd = client_fd; // 更新最大文件描述符
}
}
// 检查客户端文件描述符是否准备好
for (int i = 0; i <= max_fd; i++) {
if (FD_ISSET(i, &tempfds)) {
if (i == server_fd) {
continue; // 跳过服务器端口
}
char buffer[1024] = {0};
int valread = recv(i, buffer, 1024, 0);
if (valread <= 0) {
std::cout << "Client disconnected." << std::endl;
close(i); // 关闭客户端连接
FD_CLR(i, &readfds); // 从集合中移除
} else {
std::cout << "Message from client: " << buffer << std::endl;
send(i, buffer, strlen(buffer), 0); // 回显消息
}
}
}
}
close(server_fd);
return 0;
}
2.2 epoll(只支持linux)
2.2.1 概述
epoll
是epoll
是 Linux 内核提供的一种高效的 I/O 多路复用机制。epoll
的核心在于它将文件描述符的管理与事件通知分离,通过内核维护一个事件表来高效地处理 I/O 事件。适用于需要长时间保持连接的场景,如 Web 服务器。
epoll 的工作原理基于以下三个核心系统调用:
epoll_create:
创建一个 epoll 实例,返回一个特殊的文件描述符(epfd),用于后续操作。
内部使用红黑树存储所有监听的文件描述符(FD),并维护一个就绪链表用于存储活跃事件。
epoll_ctl:
用于管理红黑树中的文件描述符,支持添加(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)和删除(EPOLL_CTL_DEL)操作。
每个文件描述符与一个回调函数绑定,当事件发生时,内核会将事件插入到就绪链表中。
epoll_wait:
等待就绪链表中的事件发生。如果有事件,直接返回给用户态,时间复杂度为 O(1);如果没有事件,根据超时参数阻塞或立即返回
Epoll 的高效数据结构
红黑树(RB-Tree):
存储所有监听的文件描述符,支持高效的插入、删除和查找操作(时间复杂度为 O(log N)),适用于海量连接。
就绪链表(Ready List):
存储活跃事件,内核通过回调函数将事件加入链表,epoll_wait 只需遍历此链表即可,避免了全量扫描。
2.2.2 优势
相比 select
和 poll
,epoll
的优势在于:
-
更高的效率:
epoll
不需要每次调用时都遍历所有文件描述符,而是只处理就绪的文件描述符。 -
无文件描述符数量限制:
epoll
不受FD_SETSIZE
的限制,可以处理大量并发连接。 -
事件驱动:
epoll
是被动触发的,只有当文件描述符状态发生变化时,内核才会通知用户空间
2.2.3 工作模式
2.2.4 epoll 与 select/poll 的比较
特性 | select | poll | epoll |
---|---|---|---|
文件描述符限制 | 有限(默认 1024) | 无限制 | 无限制 |
效率 | 随文件描述符数量增加而降低 | 随文件描述符数量增加而降低 | 高效,不受文件描述符数量影响 |
事件通知 | 轮询 | 轮询 | 事件驱动 |
跨平台性 | 跨平台 | 跨平台 | Linux 特有 |
应用场景 | 小规模并发、实时性要求高 | 中等规模并发 | 高并发、长连接 |
Epoll 的性能优势
无需遍历全部 FD:epoll_wait 只处理就绪链表中的事件,时间复杂度为 O(1),
而 select 和 poll 的时间复杂度为 O(n)。
减少内存拷贝:通过 mmap 共享内核与用户空间内存,避免了 select 和 poll 的多次数据拷贝。
支持海量连接:红黑树结构使 epoll 可管理数十万级文件描述符,适用于现代高并发服务器。
2.2.5 示例代码
#include <iostream>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int server_fd, epoll_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
struct epoll_event event, events[10];
// 创建 TCP 套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket failed");
return -1;
}
// 绑定地址和端口
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("bind failed");
return -1;
}
// 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
return -1;
}
// 创建 epoll 实例
epoll_fd = epoll_create(1);
if (epoll_fd < 0) {
perror("epoll_create failed");
return -1;
}
// 将监听套接字加入 epoll
event.data.fd = server_fd;
event.events = EPOLLIN | EPOLLET; // 边缘触发
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) < 0) {
perror("epoll_ctl failed");
return -1;
}
std::cout << "Server is running on port 8080..." << std::endl;
while (true) {
int nfds = epoll_wait(epoll_fd, events, 10, -1);
for (int n = 0; n < nfds; ++n) {
if (events[n].data.fd == server_fd) {
// 接受新连接
int client_fd = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
if (client_fd < 0) {
perror("accept failed");
continue;
}
// 将客户端套接字加入 epoll
event.data.fd = client_fd;
event.events = EPOLLIN | EPOLLET; // 边缘触发
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);
} else {
// 处理客户端数据
char buffer[1024] = {0};
int valread = read(events[n].data.fd, buffer, sizeof(buffer));
if (valread <= 0) {
close(events[n].data.fd);
} else {
std::cout << "Message from client: " << buffer << std::endl;
write(events[n].data.fd, buffer, valread);
}
}
}
}
close(server_fd);
close(epoll_fd);
return 0;
}
3. 长连接与短连接
在网络编程中,长连接和短连接是两种常见的连接模式,它们的区别主要在于连接的持续时间和使用场景。
3.1 长连接
长连接是指客户端和服务器之间建立的连接在较长时间内保持打开状态,而不是在一次数据传输后立即关闭。这种连接模式适用于需要频繁交互的场景。
3.1.1 特点
-
持续时间长:连接在较长时间内保持打开状态,避免了频繁建立和关闭连接的开销。
-
高效:减少了连接建立和关闭的开销,适合高频率的数据交互。
-
资源占用:由于连接保持打开状态,会占用一定的系统资源(如文件描述符、内存等)。
-
适用场景:适用于需要实时交互的应用,如聊天应用、游戏服务器、WebSockets 等。
3.1.2 示例
-
WebSockets:用于实时通信,客户端和服务器之间保持一个持久的连接,数据可以随时发送。
-
游戏服务器:客户端和服务器之间需要频繁交互,长连接可以减少延迟。
-
即时通讯:如微信、QQ 等,客户端和服务器之间保持长连接,以便实时接收消息。
3.2 短连接
短连接是指客户端和服务器之间的连接在一次数据传输后立即关闭。这种连接模式适用于偶尔交互的场景。
3.2.1 特点
-
连接时间短:每次交互后立即关闭连接,避免了长时间占用资源。
-
资源占用少:由于连接时间短,系统资源占用较少。
-
开销较大:每次交互都需要建立和关闭连接,增加了连接的开销。
-
适用场景:适用于偶尔交互的应用,如传统的 HTTP 请求。
3.2.2 示例
-
HTTP/1.0:每次请求后关闭连接,适合简单的网页浏览。
-
文件下载:客户端请求文件后,下载完成后关闭连接。