一、 Socket API介绍
Socket API是一套网络通信的接口,支持 TCP、UDP协议。使用这套接口可以完成网络通信。
1.1 套接字函数
-
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形 int inet_pton(int af, const char *src, void *dst);
-
// 创建套接字 int socket(int domain, int type, int protocol);
-
// 将文件描述符和本地的IP与端口进行绑定 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
// 给套接字设置监听 int listen(int sockfd, int backlog);
-
// 等待并接受客户端的连接请求, 建立新的连接 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
-
// 接收数据 ssize_t read(int sockfd, void *buf, size_t size); ssize_t recv(int sockfd, void *buf, size_t size, int flags);
-
// 发送数据 ssize_t write(int fd, const void *buf, size_t len); ssize_t send(int fd, const void *buf, size_t len, int flags);
-
// 成功连接服务器后, 客户端会自动随机绑定一个端口 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
1.2通信流程
-
使用
socket()
创建用于监听的套接字。 -
使用
bind()
将得到的监听的文件描述符和本地的IP 端口进行绑定 -
TCP 服务端设置监听
-
服务端使用
accept()
接受客户端的连接。 -
客户端使用
connect()
连接服务器。 -
使用
send()
和recv()
(TCP)或sendto()
和recvfrom()
(UDP)在 socket 之间通信。 -
使用
close()
或shutdown()
断开连接, 关闭套接字
二、 epoll介绍
2.1定义
- epoll 全称 eventpoll,是 linux 内核实现IO多路转接/复用的一个实现。
- IO多路转接的是在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其进行读写操作。epoll是select和poll的升级版,它更加高效。
2.2原理
- 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。
- select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降。
- select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存。
- 使用epoll没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制
2.3关键函数
- epoll_create 创建epoll实例,通过一棵红黑树管理待检测集合。
- **epoll_ctl **管理红黑树上的文件描述符(添加、修改、删除)。
- epoll_wait 检测epoll树中是否有就绪的文件描述符。
2.4使用流程
- 创建监听的套接字,设置端口复用(可选)
- 使用本地的IP与端口和监听的套接字进行绑定
- 创建epoll实例对象,将用于监听的套接字添加到epoll实例中
- 检测添加到epoll实例中的文件描述符是否已就绪,并将处理已就绪的文件描述符
- 如果监听的文件描述符,和新客户端建立连接,将新文件描述符添加到epoll实例中
- 如果通信的文件描述符,和对应的客户端连接已断开,将该文件描述符从epoll实例中删除
三、 多人在线聊天程序
3.1服务端
服务器负责处理和多个客户端的网络通信,使用 epoll
实现 I/O 多路复用。
1、初始化服务器Socket
-
创建一个 TCP socket,设置 socket 地址结构。
int server_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(PORT);
-
将 socket 绑定地址和端口,然后开始监听连接请求。
bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) listen(server_fd, SOMAXCONN)
2、设置非阻塞模式
- 通过
make_socket_non_blocking
函数,将服务器socket 设置为非阻塞模式。
3、epoll配置
- 创建一个新的 epoll 实例,用于管理多个 socket 的 I/O 事件。监听服务器 socket 上的读取事件(EPOLLIN)。EPOLLET 指定使用边缘触发(edge-triggered)模式。
int efd = epoll_create1(0);
struct epoll_event event;
event.data.fd = server_fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(efd, EPOLL_CTL_ADD, server_fd, &event);
4、epoll处理代码
-
设置一个循环,当有socket准备好读写操作或产生新连接请求时,使用
epoll_wait
返回。while (true) { int num = epoll_wait(efd, events, MAX_EVENTS, -1); for (int i = 0; i < num; i++) { // 事件处理代码 } }
-
处理连接请求
使用 accept接受新连接,将新产生的客户端 socket 添加到 epoll 实例中。
-
客户端信息管理
服务器通过
std::vector<Client>
列表,记录所有已连接的客户端。以下为部分代码:struct Client { int fd; std::string name; }; struct epoll_event events[MAX_EVENTS]; std::vector<Client> clients;
-
客户端加入聊天室
服务器读取来自客户端的数据。如果是该客户端首次发送数据,服务器会查看用户名是否唯一,选择是否接受此连接。
若数据来自已加入的客户端,则服务器读取并发送该消息至其他客户端。
-
断开连接
当客户端主动断开连接或服务端显示读取操作返回 0(即客户端已断开连接),服务器删除和关闭该客户端的信息。
5、服务端部分代码
-
设置socket为非阻塞模式
int make_socket_non_blocking(int sfd) { int flags = fcntl(sfd, F_GETFL, 0); if (flags == -1) { perror("fcntl"); return -1; } flags |= O_NONBLOCK; if (fcntl(sfd, F_SETFL, flags) == -1) { perror("fcntl"); return -1; } return 0; }
-
判断用户名是否重名
bool is_name_unique(const std::string& name, const std::vector<Client>& clients) { return std::none_of(clients.begin(), clients.end(), [&name](const Client& c) { return c.name == name; }); }
-
清除消息中换行符
void trim_newline(std::string &str) {
if (!str.empty() && str[str.size() - 1] == '\n') {
str.erase(str.size() - 1);
}
if (!str.empty() && str[str.size() - 1] == '\r') {
str.erase(str.size() - 1);
}
}
- 发送消息至多个客户端实现聊天效果
void broadcast_message(const std::string& message, const std::vector<Client>& clients, int sender_fd = -1) {
std::cout << message; // Print message on server
for (const auto& client : clients) {
if (client.fd != sender_fd) {
send(client.fd, message.c_str(), message.length(), 0);
}
}
}
- 处理新连接
if (server_fd == events[i].data.fd) {
while (true) {
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
} else {
perror("accept");
break;
}
}
make_socket_non_blocking(client_fd);
event.data.fd = client_fd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, client_fd, &event) < 0) {
perror("epoll_ctl");
close(client_fd);
} else {
clients.push_back({client_fd, ""});
}
}
}
- 处理客户端消息,接收或发送
} else if (events[i].events & EPOLLIN) {
if (server_fd == events[i].data.fd) {
// 上面的代码处理了新连接,这里不会执行
} else {
// 处理已连接客户端的消息接收
// ...
if (!done) {
auto it = std::find_if(clients.begin(), clients.end(), [fd = events[i].data.fd](const Client& c) {
return c.fd == fd;
});
if (it == clients.end()) {
continue; // 新客户端,跳过处理,等待下一条消息
}
// 处理客户端消息
// ...
if (done) {
// 处理客户端离开
// ...
}
}
}
}
3.2 客户端
客户端负责与服务器建立连接,实现客户与服务端的通信。
1、创建和连接 Socket
-
创建一个 TCP socket,设置服务器的地址和端口,通过
connect
函数与服务器建立连接。int sock = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serv_addr; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(PORT); inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr); connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_ad dr));
2、新用户发送消息
getline(std::cin, name);
send(sock, (name + "\r\n").c_str(), name.length() + 2, 0);
3、接收服务端消息
-
创建一个新线程
receiverThread
,用于接收服务端消息。std::thread receiverThread(receiveMessages, sock);
4、发送消息至服务端
while (getline(std::cin, message)) {
send(sock, (message + "\r\n").c_str(), message.size() + 2, 0);
}
5、回收通信线程
-
当主线程中用户停止输入消息时,等待消息接收线程结束(
join
),关闭 socket。receiverThread.join(); close(sock);
6、客户端部分代码
-
发送用户名并处理服务器响应
- 用户被要求输入用户名,然后使用
send()
函数将用户名发送给服务器。用户名以 “\r\n” 结尾,这可能是为了与服务器端的协议兼容(换行符的使用)。 - 接着,通过
read()
函数从服务器接收响应。如果读取的字节数小于等于零,说明出现了读取错误或连接断开的情况。如果服务器的响应中不包含 “OK”,则表明用户名已经被占用,程序输出错误信息并退出。否则,用户成功加入聊天室,并输出提示信息。
std::string name; std::cout << "Enter your name: "; getline(std::cin, name); send(sock, (name + "\r\n").c_str(), name.length() + 2, 0); char buffer[BUFFER_SIZE]; memset(buffer, 0, BUFFER_SIZE); int bytes_read = read(sock, buffer, BUFFER_SIZE - 1); if (bytes_read <= 0) { std::cerr << "Failed to receive response from server\n"; close(sock); return -1; } std::string response(buffer); if (response.find("OK") == std::string::npos) { std::cerr << "Name already taken, please restart and choose another one.\n"; close(sock); return -1; } else { std::cout << name << " joined the chat\n"; }
- 用户被要求输入用户名,然后使用
3.3运行结果
1、运行3个客户端
加入聊天室后,所有用户发送的消息都可以在终端进行显示。同时也会显示用户的加入与退出消息
服务端:
客户端:
2、客户端主动退出
客户端退出后会在聊天室显示该消息
3、服务端退出
服务端退出后会在客户端的聊天室中显示该消息
四、心得体会
通过《网络程序设计》这门课程的学习,我对网络编程有了深入的理解。老师设置的多个实验项目使我能够更全面地了解计算机编程。不论是每章的基础实验还是期末的专题实验,这些实践任务让我在实际操作中巩固了课堂上学到的理论知识,有了很大进步。