总结一下Linux下常见的几种并发编程方式。

多进程并发

这是出现的最早的也是写起来最简单的一种方式。大概可以总结成父进程接收客户端的连接请求,启动子进程负责与客户端通信。

关于Linux下几种并发服务器的总结_服务器

多进程服务器代码:

#include <stdio.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

void client_handler(int fd)
{
char buf[32] = {0};
int ret;

while (1)
{
ret = recv(fd, buf, sizeof(buf), 0);
if (-1 == ret)
{
perror("recv");
exit(1);
}
else if (0 == ret)
{
break;
}
if (!strcmp(buf, "bye"))
{
break;
}
printf("%s\n", buf);

memset(buf, 0, sizeof(buf));
}

close(fd);
kill(getppid(), SIGALRM);
}

void my_wait(int sig)
{
int status;
wait(&status);
}

int main()
{
//创建socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
perror("socket");
exit(1);
}

int opt = 1;
setsockopt(sockfd,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8000);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (-1 == ret)
{
perror("bind");
exit(1);
}

ret = listen(sockfd, 10);
if (-1 == ret)
{
perror("listen");
exit(1);
}

printf("等待客户端的连接...\n");
struct sockaddr_in client_addr;
int length = sizeof(client_addr);

signal(SIGALRM, my_wait);

while (1)
{
int fd = accept(sockfd, (struct sockaddr *)&client_addr, &length);
if (-1 == fd)
{
perror("accept");
exit(1);
}
printf("接受客户端的连接 %d\n", fd);

pid_t pid = fork();
if (0 == pid)
{
client_handler(fd);
exit(0);
}
}

close(sockfd);

return 0;
}

多进程服务器优点在于:


  • 方法简单,易于理解;
  • 进程的特点在于健壮,即一个进程奔溃掉并不会影响其他进程的执行。

多进程并发的缺点也很明显:


  • 因为多个进程地址空间相互独立,所以各个进程想要实现数据的传递,必须使用指定的进程间通信方式;
  • 进程的开销比较大,如果并发量比较大,服务器的负载会变得很大。

为了解决以上两个缺点,于是就有了线程。

多线程并发

跟进程相比,线程的优点很多:


  • 资源消耗少;
  • 线程间切换速度快;
  • 线程间通信不需要复杂的通信机制;
  • 使多CPU的系统工作更加有效。

我们可以为每一个客户端创建一个线程,这样同样的并发量对服务器造成的压力会比进程小。

关于Linux下几种并发服务器的总结_#include_02

多线程服务器代码:

#include <stdio.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

void *ClientHandler(void *arg)
{
int ret;
int fd = *(int *)arg;
char buf[32] = {0};

pthread_detach(pthread_self()); //线程结束,自动释放资源

while (1)
{
ret = recv(fd, buf, sizeof(buf), 0);
if (-1 == ret)
{
perror("recv");
exit(1);
}
else if (0 == ret) //客户端异常退出
{
break;
}
printf("接收%d客户端%s\n", fd, buf);

memset(buf, 0, sizeof(buf));
}

printf("%d 客户端退出!\n", fd);
close(fd);
}

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

int opt = 1;
setsockopt(sockfd,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = 8000;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (-1 == ret)
{
perror("bind");
exit(1);
}

ret = listen(sockfd, 10);
if (-1 == ret)
{
perror("listen");
exit(1);
}

printf("等待客户端的连接...\n");
struct sockaddr_in client_addr;
int length = sizeof(client_addr);
while (1)
{
int fd = accept(sockfd, (struct sockaddr *)&client_addr, &length);
if (-1 == fd)
{
perror("accept");
exit(1);
}
printf("接受客户端的连接 %d\n", fd);

//为每一个客户端创建新的线程
pthread_t tid;
ret = pthread_create(&tid, NULL, ClientHandler, &fd);
if (ret != 0)
{
perror("pthread_create");
exit(1);
}
}

close(sockfd);

return 0;
}

是不是使用多线程实现的服务器就是完美的呢?当然不是。

多线程因为是共享同一个地址空间,所以一个线程的奔溃会导致整个进程挂掉。同时线程的通信方式过于简单,只需要读取内存就行,会导致多个线程同时访问共享资源。程序员的大部分时间都消耗在了解决多线程并发的问题上。

因此,多线程解决并发的问题也并不是完美的方案。

多路复用

并发服务器并不是只有进程和线程才能解决,还有一个目前比较流行的技术叫做多路复用。

什么是多路复用技术?

举个例子,假如你是一个老师(服务器),现在让班级里30个学生做一道数学题,做好之后你逐个检查,有这么几种方案。

第一种:从第一个人开始检查,顺序把30个人检查完。这种效率是最低的,一旦中间遇到某个学生解题没有思路,那么将会影响整个班的进度。这种方法都谈不上是并发服务器。

第二种:找来30个老师,每个老师负责检查一个学生,不难理解,效率是相当的高。这种方法就是多进程/多线程并发服务器。

第三种:你站在讲台上,告诉学生题目答完后叫一声,但是你又不知道是谁叫的,所以一听到声音你就逐个询问,直到找到这个学生并且检查他的答案。这种就是多路复用select的实现方案。

顺便讲一下第四种:你站在讲台上,告诉学生题目答完后举手,这种方法直接省去了你逐个询问,效率高于select,在服务器上使用epoll技术实现,也是多路复用的一种。

多路复用基本上可以归纳为两个因素:事件以及处理事件的函数。

程序在不断的循环,等待事件的到来,来了之后根据事件类型的不同调用不同的事件处理函数。

关于Linux下几种并发服务器的总结_Linux_03

select并发服务器代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
int fd[1024] = {0};
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
perror("socket");
exit(1);
}

int opt = 1;
setsockopt(sockfd,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = 8000;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (-1 == ret)
{
perror("bind");
exit(1);
}


ret = listen(sockfd, 10);
if (-1 == ret)
{
perror("listen");
exit(1);
}

fd_set readfd, tmpfd; //定义集合
FD_ZERO(&readfd); //清空集合
FD_SET(sockfd, &readfd); //添加到集合
int maxfd = sockfd, i = 0;
struct sockaddr_in client_addr;
int length = sizeof(client_addr);
char buf[32] = {0};

while (1)
{
tmpfd = readfd;
ret = select(maxfd + 1, &tmpfd, NULL, NULL, NULL);
if (-1 == ret)
{
perror("select");
exit(1);
}

if (FD_ISSET(sockfd, &tmpfd)) //判断sockfd是否还留在集合里面 判断是否有客户端发起连接
{
for (i = 0; i < 1024; i++) //选择合适的i
{
if (fd[i] == 0)
{
break;
}
}
fd[i] = accept(sockfd, (struct sockaddr *)&client_addr, &length);
if (-1 == fd[i])
{
perror("accept");
exit(1);
}

printf("接受来自%s的客户端的连接 fd = %d\n", inet_ntoa(client_addr.sin_addr), fd[i]);

FD_SET(fd[i], &readfd); //新的文件描述符加入到集合中
if (maxfd < fd[i])
{
maxfd = fd[i];
}
}
else //有客户端发消息
{
for (i = 0; i < 1024; i++)
{
if (FD_ISSET(fd[i], &tmpfd)) //判断是哪个fd可读
{
ret = recv(fd[i], buf, sizeof(buf), 0);
if (-1 == ret)
{
perror("recv");
}
else if (0 == ret)
{
close(fd[i]); //关闭TCP连接
FD_CLR(fd[i], &readfd); //从集合中清除掉
printf("客户端%d下线!\n", fd[i]);
fd[i] = 0;
}
else
{
printf("收到%d客户端的消息%s\n", fd[i], buf);
}
memset(buf, 0, sizeof(buf));
break;
}
}
}
}

return 0;
}

select的缺点:


  • 最大的并发数受内核的限制;
  • 每次都要扫描整个集合,集合越大,效率越低。

epoll并发服务器代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/epoll.h>

#define MAXSIZE 256

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

int opt = 1;
setsockopt(sockfd,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = 8000;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (-1 == ret)
{
perror("bind");
exit(1);
}

ret = listen(sockfd, 10);
if (-1 == ret)
{
perror("listen");
exit(1);
}
int epfd = epoll_create(MAXSIZE); //创建epoll对象
if (-1 == epfd)
{
perror("epoll_create");
exit(1);
}

struct epoll_event ev, events[MAXSIZE] = {0};
ev.events = EPOLLIN; //监听sockfd可读
ev.data.fd = sockfd;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (-1 == ret)
{
perror("epoll_ctl");
exit(1);
}

struct sockaddr_in client_addr; //用于保存客户端的信息
int length = sizeof(client_addr), i;
char buf[32] = {0};

while (1)
{
int num = epoll_wait(epfd, events, MAXSIZE, -1); //-1表示阻塞
if (-1 == num)
{
perror("epoll_wait");
exit(1);
}

for (i = 0; i < num; i++)
{
if (events[i].data.fd == sockfd) //有客户端发起连接
{
int fd = accept(sockfd, (struct sockaddr *)&client_addr, &length);
if (-1 == fd)
{
perror("accept");
exit(1);
}

printf("接受来自%s的连接fd=%d\n", inet_ntoa(client_addr.sin_addr), fd);

//为新的文件描述符注册事件
ev.data.fd = fd;
ev.events = EPOLLIN;
ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
if (-1 == ret)
{
perror("epoll_ctl");
}
}
else
{
if (events[i].events & EPOLLIN) //如果事件是可读的
{
ret = recv(events[i].data.fd, buf, sizeof(buf), 0);
if (-1 == ret)
{
perror("recv");
}
else if (0 == ret)
{
ev.data.fd = events[i].data.fd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, &ev); //客户端退出,注销事件
close(events[i].data.fd);
}
else
{
printf("收到%d客户端的消息%s\n", events[i].data.fd, buf);
}
memset(buf, 0, sizeof(buf));
}
}
}
}

return 0;
}

epoll的性能提升:


  • 没有最大并发连接的限制;
  • 主动调用回调函数,无需逐个检测。

多路复用解决了多线程中同步的问题,确实省去了很多麻烦。

但是由于只有一个线程,如果在处理某个事件的时候,遇到了IO操作,比如读取大文件,那么程序将会被阻塞,此时其他的事件将不会被处理。解决这个问题可以使用异步IO,就是读取文件的时候,不管文件有没有读完都会立即返回,不影响处理其他事件。至于IO操作什么时候完成,操作系统会有其他办法去检测。

总结

随着服务器负载的越来越大,高并发问题早已不是进程、线程或者多路复用能解决的,而是事件 + 线程 + 协程的组合,但是不管怎样,了解了历史才能更深刻的理解当下。以上就是给大家总结的几种比较经典的并发服务器实现方案。

以上有不足的地方欢迎指出讨论,最后,如果觉得学习资料难找的话,可以添加​​学习交流群:960994558​​ 学习资料已经共享在群里了,期待你的加入~

关于Linux下几种并发服务器的总结_#include_04