C语言网络编程
C语言网络编程
以前目光只盯着应用层的东西看, 对于c语言和linux等底层知识一直抱着抵触的态度, 现在因为考试不得不恶补这些知识, 才发现这些"原始"的知识所蕴含的设计思想是很有价值的, 它完善着我们的"计算机思维".
而且本文所涉及到的知识点, 在后端开发岗的面试中也是很常见的考点, 我现在深有体会: "背八股文"和"具体学习过代码"在面试中的表现是完全不同的. 背八股文你只能记住作者所说的内容, 而理解了代码, 你就理解了这种技术的使用场景
socket
socket是一种进程间通信的方式, 和信号, 共享内存等方式不同的是, 这种方式允许两个不同主机上的进程相互通信.
可以理解为socket就是一个插口, 两个主机的插口通过一根管道连接起来, 向socket写入数据就是发送数据, 从socket读取数据就是接收数据.
在linux中, socket以文件的形式存在的, 通过文件描述符(通常用一个整数表示)可以操作socket.
socket通信流程图:
字节序
网络字节序是一种标准化的字节序,用于在不同主机之间进行数据传输和通信。它采用的是大端字节序(Big-Endian)。
在大端字节序中,数据的高位字节存储在低地址,低位字节存储在高地址。例如,整数值 0x12345678
在网络字节序中被表示为 12 34 56 78
。
主机字节序是指在特定主机体系结构中使用的字节序。主机字节序可能是大端字节序或小端字节序(Little-Endian),具体取决于处理器的架构和操作系统的设定。
在大多数主机系统中,采用的是主机字节序。例如,x86 架构的处理器使用小端字节序,而大多数网络协议要求使用网络字节序,即大端字节序。
在进行网络通信时,需要确保不同主机之间发送和接收的数据字节序一致。因此,需要进行字节序的转换。常用的字节序转换函数包括 htons
、htonl
、ntohs
和 ntohl
:
htons
(Host to Network Short):将一个 16 位的主机字节序值转换为网络字节序值。htonl
(Host to Network Long):将一个 32 位的主机字节序值转换为网络字节序值。ntohs
(Network to Host Short):将一个 16 位的网络字节序值转换为主机字节序值。ntohl
(Network to Host Long):将一个 32 位的网络字节序值转换为主机字节序值。
例如, 我们本机上表示8080端口号(0x1F40)采用的是小端模式40 1F
, 我们要把它放到网络上传输就要使用htons()转换成大端模式1F 40
信号量
信号量是一种进程间通信的方式, 我们可以通过kill -信号种类 pid
向某个进程传递一个信号.
信号一共有64种:
# 列出所有信号名称:
[user2@pc] kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE
9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT
17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN
35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4
39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12
47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14
51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10
55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6
59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
# 下面是常用的信号。
# 只有第9种信号(SIGKILL)才可以无条件终止进程,其他信号进程都有权利忽略。
HUP 1 终端挂断
INT 2 中断(同 Ctrl + C)
QUIT 3 退出(同 Ctrl + \)
KILL 9 强制终止
TERM 15 终止
CONT 18 继续(与STOP相反,fg/bg命令)
STOP 19 暂停(同 Ctrl + Z)
程序可以自己决定对不同的信号做出不同的反应(使用signal()注册回调函数). 9信号除外, 进程将被强制杀死.
kill
命令默认是发送15信号(TERM终止)
由于向进程发送信号的大部分场景都是为了杀死进程, 所以该命令干脆就叫kill, 见名知意
管道通信
管道是一种进程间通信方式, 见名知意, 一方往入口塞数据, 另一方从出口读数据. 是单向的
匿名管道
仅能用于父子进程之间.
需要准备一个长度为2的int数组, 用于存放读和写的fd. 调用pipe函数, 将数组传进去, 该数组的0号下标将被赋值读fd, 1号被赋值写fd.
父子进程可以使用文件io操作函数(例如read, write)来传递数据.
记得close fd
命名管道
又称fifo, 可以用于任何进程之间.
流程:
使用mkfifo函数创建命名管道文件, 第一个参数是文件路径, 第二个参数可以写死为0666(八进制数字)
进程使用open函数打开管道文件, 第一个参数是文件路径, 第二个参数是打开模式, 有以下几种选项:
O_RDONLY
:以只读模式打开文件。O_WRONLY
:以只写模式打开文件。O_RDWR
:以读写模式打开文件。O_CREAT
:如果文件不存在则创建文件。O_TRUNC
:如果文件存在且可写,则将文件长度截断为 0。O_APPEND
:在写入文件时将数据追加到文件末尾。
进程使用文件io操作函数(例如read, write)来传递数据
记得close fd
使用unlink函数删除命名管道, 传参文件路径
多进程
fork
fork()
函数会创建一个当前进程的副本,生成一个新的子进程。新的子进程是原始进程的复制品,包括代码、数据、堆栈以及打开的文件描述符等。
fork()
函数的原型如下:
#include <unistd.h>
pid_t fork(void);
fork()
函数没有参数,返回值是一个 pid_t
(实际上就是int的别名),代表子进程的进程ID(pid)。具体的返回值情况如下:
如果
fork()
返回负值,表示创建子进程失败。如果
fork()
返回0,表示当前代码正在执行的进程是新创建的子进程。如果
fork()
返回一个正值,表示当前代码正在执行的进程是原始进程,返回值是新创建的子进程的进程ID。
下面是一个简单的示例,展示了如何使用 fork()
函数创建一个子进程:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t child_pid = fork();
if (child_pid < 0) {
// 创建子进程失败
perror("fork failed");
return -1;
} else if (child_pid == 0) {
// 在子进程中执行的代码
printf("Hello from child process!\n");
} else {
// 在父进程中执行的代码
printf("Hello from parent process! Child PID: %d\n", child_pid);
}
return 0;
}
在这个示例中,我们调用了 fork()
函数,根据返回值的不同在父进程和子进程中分别输出不同的消息。父进程中的 child_pid
是子进程的进程ID,而子进程中的 child_pid
值为0。
fork()
函数的特点是,在调用之后,原始进程(父进程)和新创建的子进程同时执行接下来的代码,但是它们在不同的进程上下文中运行。子进程是父进程的副本,从 fork()
函数的位置开始执行代码,而不会影响父进程的执行。父进程和子进程共享某些资源,如打开的文件描述符和内存映射,但各自拥有独立的地址空间。
僵尸进程
如果子进程先于父进程退出, 并且在以下任意情形之一时, 都会变成僵尸进程.
父进程没有使用wait()或waitpid()等待并捕获子进程的退出状态
父进程没有声明子进程退出时的回调函数(handler)
僵尸进程将会占用内存和进程表空间, 应当避免.
对应两种产生原因, 有两种避免方式:
第一种: 等待子进程结束
// 父进程
int status;
pid_t child_pid = wait(&status);
if (child_pid == -1) {
perror("wait failed");
return -1;
}
if (WIFEXITED(status)) {
printf("Child process %d exited with status: %d\n", child_pid, WEXITSTATUS(status));
}
第二种: 回调处理函数
// 设置SIGCHLD信号的处理程序为自己编写的函数"handle_sigchld"
signal(SIGCHLD, handle_sigchld);
如果你懒得编写回调函数, 可以不处理:
// SIG_IGN是一个空处理函数, 这样可以直接忽略子进程退出信号
signal(SIGINT, SIG_IGN);
多线程
C 语言中的多线程编程主要依赖于标准库中的 pthread
(POSIX 线程)库。
以下是多线程编程的基本步骤:
包含头文件:在程序中包含
<pthread.h>
头文件,该头文件中定义了多线程编程所需的函数和数据类型。创建线程:使用
pthread_create
函数创建一个新的线程。该函数接受多个参数,包括线程标识符、线程属性、线程函数和传递给线程函数的参数。线程函数:定义一个线程函数,它将在新线程中执行的代码(相当于java Thread类中的Runnable方法)。线程函数的原型应该是
void *function(void *args)
,其中args
是传递给线程函数的参数.线程同步:在线程之间进行同步操作,以确保线程按照所需的顺序和时序执行。常用的线程同步机制包括互斥锁(mutex)、条件变量(condition variable)、信号量(semaphore)等。
线程等待:在主线程中使用
pthread_join
函数等待其他线程的完成。这个函数将阻塞主线程,直到指定的线程执行结束。线程退出:线程函数可以通过
pthread_exit
函数显式退出线程,并返回一个指针作为线程的退出状态。
下面是一个简单的示例,展示了如何在 C 语言中创建和同步多个线程:
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 5
void *thread_func(void *thread_id) {
long tid = (long)thread_id;
printf("Hello from thread %ld\n", tid);
pthread_exit(NULL);
}
int main() {
pthread_t threads[NUM_THREADS];
for (long i = 0; i < NUM_THREADS; i++) {
int result = pthread_create(&threads[i], NULL, thread_func, (void *)i);
if (result != 0) {
printf("Failed to create thread\n");
return -1;
}
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("All threads have completed\n");
return 0;
}
在这个示例中,我们创建了 5 个线程,每个线程都打印出自己的线程ID。thread_func
函数是线程函数,它在每个线程中执行。主线程使用 pthread_join
函数等待所有线程执行
完毕,然后输出 "All threads have completed"。
可以看到, c的多线程和java的很像. 而多进程是一个比较怪的东西.
多线程服务器
多线程和多进程的理念差不多, 这里以多线程为例.
主线程在开启了对第一个socket(我们可以称之为主socket, 这是用来建立连接的socket)的监听之后, 就阻塞在accept函数那里了, 等待新的客户端连接.
每建立一个新连接, 就拿到了一个新的socket, 这是用来和客户端进行数据读写的socket, 我们可以称之为client socket.
我们可以分配一个新线程负责监听client socket的事件, 一般来说, 处理业务和向socket写入数据都是很快的, 所以这个线程的大部分时间都用来阻塞在recv方法等待读取客户端发来的数据.
而主线程在建立好连接, 分配好线程以后, 又循环回去, 继续阻塞在accept函数那里等待连接.
这是一个echo server的代码示例, 这个服务器监听客户端发来的数据, 并原封不动地发回去.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <pthread.h>
#define PORT 8080
#define BUFFER_SIZE 1024
// 线程函数,用于处理客户端连接
void *client_handler(void *arg) {
// 这个sockfd就是client socket对应的文件描述符, 是一个整数, 通过它可以向socket读写数据
int sockfd = *(int *)arg;
char buffer[BUFFER_SIZE];
// 接收和发送数据
while (1) {
memset(buffer, 0, BUFFER_SIZE);
int num_bytes = recv(sockfd, buffer, BUFFER_SIZE, 0);
if (num_bytes <= 0) {
break;
}
printf("Received: %s\n", buffer);
if (send(sockfd, buffer, num_bytes, 0) == -1) {
perror("send failed");
exit(EXIT_FAILURE);
}
}
// 关闭连接
close(sockfd);
printf("Client disconnected\n");
pthread_exit(NULL);
}
int main() {
int sockfd, newsockfd, client_len;
struct sockaddr_in client_addr;
// 创建套接字, 这三个参数适用于最常见的ipv4 + tcp的情景, 是固定的不用改
// 返回值sockfd就是主socket的文件描述符, 是一个整数, 通过它可以向socket读写数据
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址, 往结构体里面填充参数, 除了端口以外基本上都是固定的
struct sockaddr_in serv_addr
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
// 绑定套接字到指定端口
if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接请求
if (listen(sockfd, 5) == -1) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Echo server is listening on port %d...\n", PORT);
while (1) {
// 接受客户端连接
client_len = sizeof(client_addr);
newsockfd = accept(sockfd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len);
if (newsockfd == -1) {
perror("accept failed");
exit(EXIT_FAILURE);
}
printf("Client connected\n");
// 创建线程来处理客户端连接
pthread_t thread;
if (pthread_create(&thread, NULL, client_handler, &newsockfd) != 0) {
perror("pthread_create failed");
exit(EXIT_FAILURE);
}
// 分离线程,使其在退出时自动释放资源
pthread_detach(thread);
}
close(sockfd);
return 0;
}
IO多路复用
上面的代码有一个问题: 当连接数过多的时候, 将会出现大量阻塞的线程. 占用大量内存.
我们自然地想到尝试用一个线程处理多个连接的读写事件. 这就是IO多路复用. 有三种方式: select, poll, epoll
select
select可以实现一个线程同时监听多个fd, 你需要传给他一个readfds以告诉他有哪些fd需要他监听.
它是一个会阻塞的函数, 线程将会阻塞直到readfds中有文件描述符就绪, 但是select只能直到有文件描述符就绪, 但是具体是哪些他也不知道, 所以必须要遍历一遍readfds才能得到就绪的fd. 然后去执行对应的处理.
以下是 select()
函数的原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
nfds
:用于指定监控范围, 一般取readfds中的最大值 + 1, 但是也可以使用最大值常数FD_SETSIZE替代, 并免去计算nfds的麻烦. FD_SETSIZE默认是1024, 这也就意味着select能监听的文件描述符数量最多是1024个.readfds
:指向一组待监视的文件描述符集合. 这其实就是一个bitMap(位图), 使用0和1表示对应下标的文件描述符是否就绪.writefds
:很少用, 基本都置null. 指向一组待监视的文件描述符集合,用于检查是否可以进行写操作。exceptfds
:很少用, 基本都置null. 指向一组待监视的文件描述符集合,用于检查是否发生异常情况。timeout
:超时时间,指定select()
函数的阻塞时间限制,如果为NULL
,表示一直阻塞直到有文件描述符准备好或者有信号中断。
select()
函数的返回值表示发生事件的文件描述符的个数。如果返回值为 -1,表示出现了错误,可以通过查看 errno
来获取错误码。如果返回值为 0,表示在超时时间内没有任何文件描述符就绪。
使用流程:
初始化并设置文件描述符集合。
fd_set readfds;
使用
FD_ZERO
宏清空文件描述符集合,并使用FD_SET
宏将需要监视的文件描述符添加到集合中。FD_ZERO(&readfds); // 添加需要监视的文件描述符到集合中 // 这里假设有 3 个文件描述符需要监视 int fd1 = 3, fd2 = 5, fd3 = 7; FD_SET(fd1, &readfds); FD_SET(fd2, &readfds); FD_SET(fd3, &readfds);
调用
select()
函数,等待文件描述符就绪。result = select(nfds, &readfds, NULL, NULL, NULL); if (result == -1) { perror("select failed"); return -1; } else if (result == 0) { // 并没有设置超时参数, 所以不会走到这一个if分支中 } else { // 遍历readfds, 找出就绪的文件描述符(可能是多个) // 对对应的socket进行读取等操作 }
注意: select函数会改变传入的readfds的内容, 所以建议每次初始化一个新的readfds传进去
以下是一个使用select实现echo server的代码示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/select.h>
#define PORT 8080
#define MAX_CLIENTS FD_SETSIZE
#define BUFFER_SIZE 1024
int main() {
int sockfd, client_sockets[MAX_CLIENTS], activity, i, valread;
int client_len, newsockfd, sd;
struct sockaddr_in serv_addr, client_addr;
char buffer[BUFFER_SIZE];
fd_set readfds;
// 创建主套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
// 绑定套接字到指定端口
if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接请求
if (listen(sockfd, 5) == -1) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Echo server is listening on port %d...\n", PORT);
// 初始化客户端套接字数组
for (i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = 0;
}
while (1) {
// 清空可读文件描述符集合
FD_ZERO(&readfds);
// 添加主套接字到可读文件描述符集合
FD_SET(sockfd, &readfds);
// 添加客户端套接字到可读文件描述符集合
for (i = 0; i < MAX_CLIENTS; i++) {
sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &readfds);
}
}
// 使用 select 监听可读文件描述符集合
activity = select(FD_SETSIZE, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
perror("select error");
}
// 如果主套接字有可读事件,表示有新的连接请求
if (FD_ISSET(sockfd, &readfds)) {
client_len = sizeof(client_addr);
newsockfd = accept(sockfd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len);
if (newsockfd == -1) {
perror("accept failed");
exit(EXIT_FAILURE);
}
printf("Client connected\n");
// 将新的客户端套接字添加到数组
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = newsockfd;
break;
}
}
}
// 处理客户端请求
for (i = 0; i < MAX_CLIENTS; i++) {
sd = client_sockets[i];
if (sd > 0 && FD_ISSET(sd, &readfds)) {
memset(buffer, 0, BUFFER_SIZE);
valread = recv(sd, buffer, BUFFER_SIZE, 0);
if (valread <= 0) {
// 客户端关闭连接
getpeername(sd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len);
printf("Client disconnected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(sd);
client_sockets[i] = 0;
} else {
// 将接收到的数据原样发送回客户端
send(sd, buffer, valread, 0);
}
}
}
}
// 关闭主套接字
close(sockfd);
return 0;
}
poll
以下是 poll()
函数的原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
fds
:指向pollfd
结构体数组的指针,每个结构体描述一个要监视的文件描述符及其所关注的事件。nfds
:要监视的文件描述符的数量。timeout
:超时时间(以毫秒为单位),控制poll()
函数的阻塞时间,可以指定以下几个值:timeout
> 0:阻塞等待直到有事件发生或超时。timeout
= 0:立即返回,轮询一次文件描述符并立即返回结果。timeout
< 0:无限期阻塞,直到有事件发生。
poll()
函数的工作原理如下:
创建一个
pollfd
结构体数组,每个结构体用于描述一个待监视的文件描述符及其所关注的事件。将需要监视的文件描述符和所关注的事件填充到
pollfd
结构体数组中。调用
poll()
函数,等待文件描述符就绪或超时。poll()
函数返回后,检查pollfd
结构体数组中的revents
字段,以确定哪些文件描述符有事件发生。
poll和select类似, 区别如下:
poll的fds是一个指向pollfd结构体数组的指针, 而不是select那样有大小限制的位图, 这就使得poll能监控的文件描述符数量理论上来说是无限的.
poll并不会像select那样修改fds, 这使得你可以重复使用fds. 简化了代码.
使用poll改写的代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <poll.h>
#define PORT 8080
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int sockfd, client_sockets[MAX_CLIENTS], i, valread;
int client_len, newsockfd, sd;
struct sockaddr_in serv_addr, client_addr;
char buffer[BUFFER_SIZE];
struct pollfd fds[MAX_CLIENTS + 1];
// 创建主套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
// 绑定套接字到指定端口
if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接请求
if (listen(sockfd, 5) == -1) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Echo server is listening on port %d...\n", PORT);
// 初始化客户端套接字数组
for (i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = 0;
}
// 将主套接字添加到 pollfd 结构体数组
fds[0].fd = sockfd;
fds[0].events = POLLIN;
while (1) {
// 使用 poll 监听可读事件
int activity = poll(fds, MAX_CLIENTS + 1, -1);
if (activity < 0) {
perror("poll error");
break;
}
// 如果主套接字有可读事件,表示有新的连接请求
if (fds[0].revents & POLLIN) {
client_len = sizeof(client_addr);
newsockfd = accept(sockfd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len);
if (newsockfd == -1) {
perror("accept failed");
exit(EXIT_FAILURE);
}
printf("Client connected\n");
// 将新的客户端套接字添加到数组
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = newsockfd;
break;
}
}
// 将新的客户端套接字添加到 pollfd 结构体数组
fds[i + 1].fd = newsockfd;
fds[i + 1].events = POLLIN;
}
// 处理客户端请求
for (i = 0; i < MAX_CLIENTS; i++) {
sd = client_sockets[i];
if (sd > 0 && fds[i + 1].revents & POLLIN) {
memset(buffer, 0, BUFFER_SIZE);
valread = recv(sd, buffer, BUFFER_SIZE, 0);
if (valread <= 0) {
// 客户端关闭连接
getpeername(sd, (struct sockaddr *)&client_addr, (socklen_t *)&client_len);
printf("Client disconnected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(sd);
client_sockets[i] = 0;
fds[i + 1].fd = -1;
} else {
// 将接收到的数据原样发送回客户端
send(sd, buffer, valread, 0);
}
}
}
}
// 关闭主套接字
close(sockfd);
return 0;
}
poll和select都有一个问题就是他们必须要靠遍历fds来找出就绪的fd, 效率低. 所以后来出现了epoll
epoll
使用流程:
使用epoll_create()创建epoll实例, 该函数接收一个int参数, 现在已经没有意义, 随便传一个值就行, 比如0.
返回值也是一个fd, 所以需要在程序的结尾close
使用epoll_ctl()向epoll实例注册一个fd, 以及感兴趣的事件类型
调用epoll_wait(), 程序将阻塞, 直至监听的所有fd里面有fd就绪. 该函数有一个参数是epoll_event结构体数组, 返回就绪的fd数量. 程序可以通过循环epoll_event数组获取就绪的fd以及事件类型.
epoll和select/poll的区别:
在有fd就绪时, 可以直接获取对应的fd, 无需遍历, 效率高
epoll使用了内核态用户态共享内存, 避免了文件描述符的拷贝开销
epoll实现echo server示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024
int main() {
int sockfd, clientfd, epollfd, nfds, i, len;
struct epoll_event event, events[MAX_EVENTS];
struct sockaddr_in serv_addr, client_addr;
char buffer[BUFFER_SIZE];
// 创建主套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);
// 绑定套接字到指定端口
if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听连接请求
if (listen(sockfd, 5) == -1) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("Echo server is listening on port %d...\n", PORT);
// 创建 epoll 实例
epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1 failed");
exit(EXIT_FAILURE);
}
// 添加主套接字到 epoll 实例
event.events = EPOLLIN;
event.data.fd = sockfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &event) == -1) {
perror("epoll_ctl failed");
exit(EXIT_FAILURE);
}
while (1) {
// 等待事件发生
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait failed");
exit(EXIT_FAILURE);
}
// 处理事件
for (i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
// 接收新的连接请求
len = sizeof(client_addr);
clientfd = accept(sockfd, (struct sockaddr *)&client_addr, (socklen_t *)&len);
if (clientfd == -1) {
perror("accept failed");
exit(EXIT_FAILURE);
}
printf("Client connected\n");
// 将新的客户端套接字添加到 epoll 实例
event.events = EPOLLIN;
event.data.fd = clientfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &event) == -1) {
perror("epoll_ctl failed");
exit(EXIT_FAILURE);
}
} else {
// 处理客户端请求
memset(buffer, 0, BUFFER_SIZE);
len = read(events[i].data.fd, buffer, BUFFER_SIZE);
if (len <= 0) {
// 客户端关闭连接
printf("Client disconnected\n");
close(events[i].data.fd);
} else {
// 将接收到的数据原样发送回客户端
write(events[i].data.fd, buffer, len);
}
}
}
}
// 关闭主套接字和 epoll 实例
close(sockfd);
close(epollfd);
return 0;
}
一道常见的面试题: redis的io模型是什么? 其实就是epoll, 这使得redis可以用一个线程处理大量客户端连接.