在当今数字化时代,高并发服务器的需求日益增长,尤其是在处理大量用户请求的场景下,C语言作为一种高效、灵活的编程语言,常被用于构建高性能的服务器系统,下面将详细介绍如何使用C语言构建高并发服务器:
1、多进程模型
原理:通过创建多个子进程来处理客户端连接,每个子进程独立运行,拥有自己的地址空间,这样即使某个子进程出现问题,也不会影响到其他子进程和整个服务器的运行,父进程通常负责监听网络端口,接受客户端连接请求,然后将建立的连接分配给子进程进行处理。
示例代码
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <string.h> #define PORT 8080 #define BACKLOG 10 int main() { int server_fd, new_socket; struct sockaddr_in address; int addrlen = sizeof(address); // 创建套接字 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(PORT); if (bind(server_fd, (struct sockaddr )&address, sizeof(address))<0) { perror("bind failed"); exit(EXIT_FAILURE); } // 开始监听是否有客户端连接 if (listen(server_fd, BACKLOG) < 0) { perror("listen"); exit(EXIT_FAILURE); } while(1) { // 接受客户端连接 if ((new_socket = accept(server_fd, (struct sockaddr )&address, (socklen_t)&addrlen))<0) { perror("accept"); exit(EXIT_FAILURE); } // 创建子进程来处理新的连接 pid_t pid = fork(); if (pid == 0) { // 子进程 close(server_fd); // 子进程不需要监听套接字 char buffer[1024] = {0}; read(new_socket, buffer, 1024); printf("Message from client: %s ", buffer); char message = "Hello from server"; send(new_socket, message, strlen(message), 0); close(new_socket); exit(0); } else if (pid < 0) { // 出错情况 perror("fork failed"); exit(EXIT_FAILURE); } else { // 父进程 close(new_socket); // 父进程不需要已连接的套接字 } } return 0; }
优缺点
优点:实现相对简单,利用了操作系统对进程的管理和隔离机制,稳定性较高,在处理大量并发连接时,可以将负载分散到多个进程中,充分利用多核CPU资源。
缺点:进程间通信开销较大,创建和管理大量进程会消耗较多的系统资源,如内存和CPU时间,对于每个连接都需要创建一个新进程,当连接数非常多时,可能会受到系统资源的限制。
2、多线程模型
原理:与多进程类似,但所有线程共享同一个进程的地址空间,主线程负责监听端口,接受客户端连接请求,然后为每个连接创建一个新的线程来处理,由于线程之间共享数据空间,所以在进行数据共享和通信时更加高效。
示例代码
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #define PORT 8080 void client_handler(void socket_desc) { int sock = (int)socket_desc; char client_message[2000]; // 接收客户端发送的消息 if(recv(sock, client_message, 2000, 0) > 0) { printf("Client: %s ", client_message); } // 发送消息给客户端 char message = "Hello from server"; send(sock, message, strlen(message), 0); close(sock); free(socket_desc); return 0; } int main() { int socket_desc, client_sock, c; struct sockaddr_in server, client; pthread_t thread_id; // 创建套接字 socket_desc = socket(AF_INET, SOCK_STREAM, 0); if (socket_desc == -1) { printf("Could not create socket"); } puts("Socket created"); // 准备sockaddr_in结构体 server.sin_family = AF_INET; server.sin_addr.s_addr = INADDR_ANY; server.sin_port = htons(PORT); // 绑定 if(bind(socket_desc, (struct sockaddr )&server, sizeof(server)) < 0) { perror("bind failed. Error"); return 1; } puts("bind done"); // 监听 listen(socket_desc, 3); // 等待并接受连接 puts("Waiting for incoming connections..."); c = sizeof(struct sockaddr_in); while((client_sock = accept(socket_desc, (struct sockaddr )&client, (socklen_t)&c))) { puts("Connection accepted"); pthread_t sniffer_thread; int new_sock = malloc(1); new_sock = client_sock; if(pthread_create(&sniffer_thread, NULL, client_handler, (void) new_sock) < 0) { perror("could not create thread"); return 1; } pthread_detach(sniffer_thread); } if (client_sock < 0) { perror("accept failed"); return 1; } return 0; }
优缺点
优点:线程创建和上下文切换的开销比进程小,可以更高效地利用CPU资源,共享内存使得线程间通信更加便捷,适合处理需要频繁共享数据的并发场景。
缺点:由于所有线程共享同一个进程的地址空间,一个线程的错误可能会导致整个进程崩溃,稳定性相对较差,过多的线程可能会导致竞争条件和死锁等问题,需要进行仔细的同步和互斥处理。
3、I/O多路复用模型(select、poll、epoll)
select模型
原理:select
函数可以监视多个文件描述符的状态变化,包括套接字,通过将多个套接字的文件描述符集合传递给select
函数,它可以同时监测这些套接符是否有可读、可写或异常事件,当有事件发生时,select
函数返回,程序可以根据返回的结果来确定哪些套接字有事件发生,并进行相应的处理。
示例代码
#include <stdio.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <string.h> #include <sys/select.h> #define PORT 8080 #define MAX_CLIENTS 30 int main() { int server_fd, new_socket; struct sockaddr_in address; int opt = 1; int addrlen = sizeof(address); char buffer[1024] = {0}; fd_set readfds; // 创建套接字文件描述符 if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("socket failed"); exit(EXIT_FAILURE); } // 强制绑定套接字到端口8080 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) { perror("setsockopt"); exit(EXIT_FAILURE); } address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(PORT); // 绑定套接字到端口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); } // 清空文件描述符集 FD_ZERO(&readfds); // 将套接字描述符加入到文件描述符集中 FD_SET(server_fd, &readfds); while(1) { // 等待文件描述符就绪,没有设置超时时间则一直等待 int activity = select(FD_SETSIZE, &readfds, NULL, NULL, NULL); if ((activity < 0) && (errno!=EINTR)) { perror("select error"); } // 如果发现文件描述符为可读状态,则进行相应处理 if (FD_ISSET(server_fd, &readfds)) { if ((new_socket = accept(server_fd, (struct sockaddr )&address, (socklen_t)&addrlen))<0) { perror("accept"); exit(EXIT_FAILURE); } // 打印客户端信息并清空缓冲区 printf("New connection, socket fd is %d, ip is : %s, port : %d ", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port)); memset(buffer, 0, sizeof(buffer)); // 将新的套接字描述符加入到文件描述符集中,以便后续再次调用select函数时可以监视该套接字 FD_SET(new_socket, &readfds); } else { // 遍历所有的文件描述符,检查是否有可读事件发生 for (int i = 0; i < FD_SETSIZE; i++) { if (FD_ISSET(i, &readfds)) { // 如果没有错误发生,并且套接字已经准备好读取数据,则读取数据并发送响应 if (read(i, buffer, sizeof(buffer)) > 0) { printf("Client: %s ", buffer); char message = "Hello from server"; send(i, message, strlen(message), 0); memset(buffer, 0, sizeof(buffer)); } else { close(i); FD_CLR(i, &readfds); } } } } } return 0; }
优缺点
优点:可以在一个线程内同时处理多个套接字的I/O事件,减少了线程上下文切换的开销,提高了性能,适用于处理大量并发连接且每个连接的数据交互量不大的场景。
缺点:select
函数有一个文件描述符数量的限制(一般为1024),无法处理大量的并发连接。select
函数在每次调用时都需要重新设置文件描述符集,效率相对较低。
poll模型
原理:poll
是select
的一个改进版本,它使用一个pollfd
结构体数组来代替select
中的文件描述符集,每个pollfd
结构体包含一个文件描述符、一个事件标志和一个请求的事件类型,通过调用poll
函数,可以同时监测多个文件描述符的事件,并根据返回的结果进行处理,与select
相比,poll
没有文件描述符数量的限制,并且不需要在每次调用前重新设置文件描述符集。
示例代码(与select模型类似,只需将相关函数替换为poll即可,此处不再赘述)
epoll模型(Linux特有)
原理:epoll
是一种高效的I/O多路复用技术,它基于事件驱动机制。epoll
通过维护一个事件列表来管理多个文件描述符的状态变化,当调用epoll_wait
函数时,它会阻塞直到有事件发生或者超时。epoll
只会返回那些真正有事件发生的文件描述符,避免了不必要的遍历,提高了效率。epoll
还支持边缘触发模式(ET模式)和水平触发模式(LT模式),可以根据具体需求进行选择。
示例代码(与select模型类似,只需将相关函数替换为epoll即可,此处不再赘述)
优缺点:epoll
的优点是在处理大量并发连接时性能非常出色,它的事件通知机制更加高效,不会像select
和poll
那样随着文件描述符数量的增加而线性下降。epoll
还支持多种事件类型和触发模式,具有很高的灵活性。epoll
是Linux特有的技术,不具有跨平台性,如果需要在非Linux系统上使用类似的功能,可能需要采用其他技术或者库来实现。
以下是关于C语言高并发服务器的两个常见问题及解答:
问题1:多进程和多线程模型在高并发服务器中如何选择?
解答:选择多进程还是多线程模型取决于具体的应用场景和需求,如果对稳定性要求较高,希望各个进程之间相互隔离,避免一个进程的崩溃影响到其他进程,那么可以选择多进程模型,但如果对性能要求极高,需要更高效的资源共享和通信,且能够处理好线程同步和互斥等问题,多线程模型可能更适合,在一些对实时性要求较高的网络应用中,多线程可以减少上下文切换的开销,提高响应速度;而在一些需要严格隔离和稳定性的场景中,多进程则更为合适。
问题2:I/O多路复用模型中的epoll相比select和poll有什么优势?
解答:epoll相比select和poll具有以下优势,epoll没有文件描述符数量的限制,可以支持更多的并发连接,这对于处理大规模高并发场景非常重要,epoll只需要在事件发生时才返回有事件的文件描述符,避免了像select和poll那样对所有文件描述符进行轮询,提高了效率,epoll还支持边缘触发模式和水平触发模式,提供了更多的灵活性,epoll在内核层面的实现更加高效,能够更好地利用系统资源,因此在处理大量并发连接时性能更优。