Linux网络socket之基础模型(c语言)

一、常见的头文件

// myhead.h
#ifndef _MYHEAD_H
#define _MYHEAD_H
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <linux/input.h>  //跟输入子系统模型有关的头文件
#include <dirent.h>
#include <stdbool.h>
#include <strings.h>
#include <sys/wait.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/msg.h>
#include <sys/sem.h>
#include <pthread.h>
#include <semaphore.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <net/if.h>
#include <netdb.h>
#include <poll.h>
#include <sys/epoll.h>
#endif

二、一连接一线程模型

简介:一连接一线程模型,就是为每一个客户端的连接创建一个线程来处理。

#include "myhead.h"
void* client_thread(void *arg){
	int clientfd = *(int*)arg; // 获取客户端的套接字

	while(1){
		char buffer[1024] = {0};
		int count = recv(clientfd, buffer, 1024, 0);
		if(count == 0){ // 当客户端断开时,recv会返回0
			printf("client disconnect: %d\n", clientfd);
			close(clientfd);
			break;
		}
		printf("RECV: %s\n", buffer); //打印数据
		count = send(clientfd, buffer, count, 0);
		printf("SEND: %d\n", count); //打印接收到的字节数
	}
}
int main()[
	// 创建服务器的套接字,AF_INIET:网际  SOCK_STREAM:字节流,代表TCP, 
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);
	struct sockaddr_in servadder; //为了填入属性,用以和套接字绑定
	servadder.sin_family = AF_INET;
	servadder.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY: 0.0.0.0 代表任意可适配IP地址
	servadder.sin_port = htons(2000); //端口号
	// 绑定套接字
	if( bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))== -1 ){
		printf("bind failed: %s\n", strerror(errno));
	}
	
	listen(sockfd, 10);  // 最大允许监听的客户端数量:10
	printf("listen finished: %d\n", sockfd);
	
	struct sockaddr_in clientaddr; // 存放客户端的属性,包括客户端的IP、端口
	socklen_t len = sizeof(clientaddr);

	while(1){
		printf("accept\n");
		// 服务器的套接字和客户端的套接字建立连接
		int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
		printf("accept finished: %d\n", clientfd);
		
		pthread_t thid;
		// thid: 线程ID号,NULL:线程的属性,client_thread:任务函数,clientfd:函数参数
		pthread_create(&thid, NULL, client_thread, &clientfd);
	}
	return 0;
}

优点:代码结构简单清晰;
缺点:不利于并发(试想百万并发,需要百万线程,这是非常消耗cpu的事情)

三、多路复用I/O模型

多路复用IO模型虽然也是阻塞的,但是阻塞的维度不一样,它是在一批文件描述符都没有置位的时候,才阻塞,而不是在某个套接字accept的时候,或者send、recv的时候阻塞。前者是批量处理,后者是单个处理。虽然都是同步阻塞的。但是多路复用中,我们会发现底层的accept、send、recv等变成了异步的,程序不需要阻塞在这些地方,如果需要程序处理,直接将相应的文件描述符置位通知程序即可,这明显提高了并发性。
这说明,有时候解决问题的高级手段是降维打击。

下面三个模型越来越先进

select模型

select的函数原型:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// nfds:需要检查的文件描述符的最大值加一。
// readfds:指向 fd_set 的指针,表示需要检查可读性的文件描述符集合。
// writefds:指向 fd_set 的指针,表示需要检查可写性的文件描述符集合。
// exceptfds:指向 fd_set 的指针,表示需要检查异常条件的文件描述符集合。
// timeout:指向 struct timeval 的指针,用于设置超时时间。如果设置为 NULL,则 select 将无限期地等待直到至少一个文件描述符准备好。

总结:
使用 select 函数时,通常会先设置好 fd_set 集合,然后调用 select。select 调用后,会阻塞直到以下任一条件满足:

  1. 至少一个在 readfds 中的文件描述符准备好读取。
  2. 至少一个在 writefds 中的文件描述符准备好写入。
  3. 至少一个在 exceptfds 中的文件描述符有异常条件。
  4. 超时时间到达。

select 调用返回后,可以通过 FD_ISSET 宏来检查哪些文件描述符发生了指定的I/O事件。例如,如果 readfds 中的文件描述符准备好读取,则可以使用 FD_ISSET 来检查哪些具体的文件描述符发生了可读事件,并对其进行处理。
例子:

#include "myhead.h"
int main() {
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in servaddr;
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
	servaddr.sin_port = htons(2000); // 0-1023, 

	if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s\n", strerror(errno));
	}
	listen(sockfd, 10);
	printf("listen finshed: %d\n", sockfd); // 3 

	struct sockaddr_in  clientaddr;
	socklen_t len = sizeof(clientaddr);
	fd_set rfds, rset;

	FD_ZERO(&rfds);
	FD_SET(sockfd, &rfds);

	int maxfd = sockfd;

	while (1) {
		rset = rfds; //这里
		// 如果rset中有可读的文件描述符,就返回,没有可读,就阻塞
		int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

		if (FD_ISSET(sockfd, &rset)) { // accept

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
			printf("accept finshed: %d\n", clientfd);

			FD_SET(clientfd, &rfds); // 
			
			if (clientfd > maxfd) maxfd = clientfd;
		}

		// recv
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd

			if (FD_ISSET(i, &rset)) {
				char buffer[1024] = {0};
				
				int count = recv(i, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", i);
					close(i);
					FD_CLR(i, &rfds);
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(i, buffer, count, 0);
				printf("SEND: %d\n", count);

			}

		}
		
	}
	getchar();

	printf("exit\n");

	return 0;
}

注释:不直接使用rfds, 新建一个 rset 是为了在每次循环中都能使用一个“干净”的文件描述符集合来调用 select 函数。这是因为在 select 函数调用之后,rset 中的文件描述符集合会被修改,以反映哪些文件描述符实际上发生了事件。如果在循环中直接使用 rfds,那么每次循环时都会基于上一次循环的状态,这可能会导致错误的行为。

poll模型

poll的函数原型:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

fds:指向 struct pollfd 数组的指针,每个 struct pollfd 结构体用于指定一个要监视的文件描述符及其事件。
nfds:fds 数组中 struct pollfd 结构体的数量,即要监视的文件描述符的总数。
timeout:等待 I/O 事件发生的时间上限,单位是毫秒。如果设置为 -1,则 poll 将无限期地等待;如果设置为 0,则 poll 将立即返回。

poll 函数的返回值是以下几种情况之一:

大于0:表示在 fds 数组中至少有一个文件描述符准备就绪(即至少有一个事件发生)。
返回值是准备就绪的文件描述符数量。
你可以通过检查 fds[i].revents 来确定具体哪个文件描述符发生了什么事件。

0:表示在 timeout 指定的等待时间内没有文件描述符准备就绪。
如果 timeout 设置为 -1,则表示无限期等待,但这里返回 0 意味着没有事件发生。

小于0:表示发生错误。
在这种情况中,你应该检查 errno 来确定错误的类型。

struct pollfd 结构体的定义如下:

struct pollfd {
int fd; // 文件描述符
short events; // 请求的事件
short revents; // 实际发生的事件
};

events 和 revents 可以是以下常量的组合:
POLLIN:有数据可读。
POLLOUT:可以写入数据。
POLLERR:发生错误。
POLLHUP:发生挂起。
POLLNVAL:无效请求。
例子:

#include "myhead.h"
int main() {
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in servaddr;
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
	servaddr.sin_port = htons(2000); // 0-1023, 

	if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s\n", strerror(errno));
	}

	listen(sockfd, 10);
	printf("listen finshed: %d\n", sockfd); // 3 

	struct sockaddr_in  clientaddr;
	socklen_t len = sizeof(clientaddr);
	
	struct pollfd fds[1024] = {0};
	fds[sockfd].fd = sockfd;
	fds[sockfd].events = POLLIN;
	// revents由系统写入
	int maxfd = sockfd;

	while (1) {

		int nready = poll(fds, maxfd+1, -1);

		if (fds[sockfd].revents & POLLIN) {
			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
			printf("accept finshed: %d\n", clientfd);

			//FD_SET(clientfd, &rfds); // 
			fds[clientfd].fd = clientfd;
			fds[clientfd].events = POLLIN;
			
			if (clientfd > maxfd) maxfd = clientfd;

		}
	
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd

			if (fds[i].revents & POLLIN) {

				char buffer[1024] = {0};
				
				int count = recv(i, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", i);
					close(i);

					fds[i].fd = -1;
					fds[i].events = 0;
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(i, buffer, count, 0);
				printf("SEND: %d\n", count);

			}
		}
	}
	getchar();
	printf("exit\n");
	return 0;
}

epoll模型

以下是使用 epoll 的基本步骤:

  1. 创建 epoll 实例:
    使用 epoll_create 或 epoll_create1 函数创建一个 epoll 实例。
    准备 struct epoll_event 结构体:
    epoll 操作使用 struct epoll_event 结构体来指定要监视的事件和用户数据。

  2. 添加/修改要监视的文件描述符:
    使用 epoll_ctl 函数将文件描述符添加到 epoll 实例中,并指定要监视的事件。

  3. 等待事件:
    使用 epoll_wait 函数等待 epoll 实例上的事件。这个函数会阻塞,直到有事件发生或超时。

  4. 处理事件:
    当 epoll_wait 返回时,遍历返回的 epoll_event 结构体数组,处理每个发生的事件。

  5. 重复步骤 4 和 5:
    在处理完所有事件后,再次调用 epoll_wait 等待下一次事件。

#include "myhead.h"
int main() {
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in servaddr;
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
	servaddr.sin_port = htons(2000); // 0-1023, 

	if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s\n", strerror(errno));
	}

	listen(sockfd, 10);
	printf("listen finshed: %d\n", sockfd); // 3 

	struct sockaddr_in  clientaddr;
	socklen_t len = sizeof(clientaddr);


	int epfd = epoll_create(1); //参数无意义,传入非-1即可,历史遗留问题

	struct epoll_event ev; // 属性配置
	ev.events = EPOLLIN;
	ev.data.fd = sockfd;
	epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

	while (1) {

		struct epoll_event events[1024] = {0}; // 创建时间数组,用来存放待响应的事件
		// 如果epfd中有需要响应的事件,就会放置到events数组中,返回值是待响应的个数
		int nready = epoll_wait(epfd, events, 1024, -1); 

		int i = 0;
		for (i = 0;i < nready;i ++) {
			int connfd = events[i].data.fd;
			if (connfd == sockfd) {

				
				int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
				printf("accept finshed: %d\n", clientfd);

				ev.events = EPOLLIN;
				ev.data.fd = clientfd;
				epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
				
			} else if (events[i].events & EPOLLIN) {

				char buffer[1024] = {0};
				
				int count = recv(connfd, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", connfd);
					close(connfd);
					epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(connfd, buffer, count, 0);
				printf("SEND: %d\n", count);

			}
		}
	}
	getchar();
	printf("exit\n");
	return 0;
}

epoll_create

int epoll_create(int size);
  • 返回值:成功时返回一个非负的文件描述符,指向新的 epoll 实例。出错时返回 -1 并设置 errno。
  • 参数
    • size:这个参数在早期的 Linux 内核中用于指示 epoll 实例可以处理的文件描述符的数量。从 Linux 2.6.8 开始,这个参数被忽略,因为 epoll 实例可以处理的最大文件描述符数量由内核参数 /proc/sys/fs/epoll/max_user_instances 决定。

epoll_create1

int epoll_create1(int flags);
  • 返回值:成功时返回一个非负的文件描述符,指向新的 epoll 实例。出错时返回 -1 并设置 errno。
  • 参数
    • flags:标志位,目前只支持 EPOLL_CLOEXEC。如果设置了这个标志,那么当执行 execve 系统调用时,epoll 实例的文件描述符将自动关闭。

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 返回值:成功时返回 0。出错时返回 -1 并设置 errno。
  • 参数
    • epfdepoll 实例的文件描述符。
    • op:操作码,指定要执行的操作。可以是以下之一:
      • EPOLL_CTL_ADD:将新的 fd 添加到 epfd 指示的 epoll 实例中。
      • EPOLL_CTL_MOD:修改已添加的 fd 的监听事件。
      • EPOLL_CTL_DEL:从 epfd 指示的 epoll 实例中移除 fd。
    • fd:要操作的文件描述符。
    • event:指向 epoll_event 结构体的指针,该结构体定义了要监视的事件和用户数据。
      epoll_event 结构体的定义如下:
struct epoll_event {
    uint32_t     events;    // 请求的事件
    epoll_data_t data;      // 用户数据
};
// epoll_data_t 是一个联合体,可以用来存储整数、指针或两者都有:
union epoll_data_t {
    void        *ptr;
    int         fd;
    uint32_t    u32;
    uint64_t    u64;
};

events 字段可以是以下常量的组合:

  • EPOLLIN:表示对应的文件描述符上有可读事件。
  • EPOLLOUT:表示对应的文件描述符上有可写事件。
  • EPOLLRDHUP:表示对端关闭连接。
  • EPOLLPRI:表示对应的文件描述符上有紧急数据可读。
  • EPOLLERR:表示对应的文件描述符上发生错误。
  • EPOLLHUP:表示对应的文件描述符上发生挂起。
  • EPOLLET:设置边缘触发(Edge Triggered)模式。(默认是水平触发,边缘触发即数据从无到有只触发一次,而水平触发阔以一直触发)
  • EPOLLONESHOT:设置一次性触发模式,触发后自动禁用该 fd,直到再次启用。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 返回值:成功时返回就绪的文件描述符数量。出错时返回 -1 并设置 errno。如果超时时间到达且没有文件描述符准备好,返回 0
  • 参数
    • epfdepoll 实例的文件描述符,由 epoll_createepoll_create1 返回。
    • events:指向 epoll_event 结构体数组的指针,用于接收就绪事件的详细信息。
    • maxeventsevents 数组中可以返回的最大事件数量。这个值必须大于 0
    • timeout:等待事件的超时时间,单位是毫秒。如果设置为 -1,则 epoll_wait 将无限期地等待直到至少一个文件描述符准备好。如果设置为 0,则 epoll_wait 将立即返回,不会等待。

这些函数是 epoll API 的核心组成部分,它们允许用户创建和管理 epoll 实例,以及向其添加、修改或删除要监视的文件描述符及其相关事件。通过这种方式,epoll 提供了一种高效的方式来处理大量的并发 I/O 事件。

epoll模型机制详解(重要)

epoll 机制概述

  1. 绑定事件(注册事件):
    • 当我们使用 epoll_ctl 注册一个文件描述符及其事件类型(例如 EPOLLIN, EPOLLOUT)时,实际上是在告诉内核我们对该文件描述符的哪些事件感兴趣。
    • 注册事件后,文件描述符会被添加到 epoll 实例中,并与相应的事件类型进行绑定。这一步并不会立即触发事件处理,只是设置了感兴趣的事件类型。
  2. 事件触发:
    • 内核在监控文件描述符时,如果检测到与注册的事件类型匹配的事件(例如一个文件描述符上有可读数据,对应 EPOLLIN 事件),就会将该文件描述符标记为就绪,并将其加入到就绪队列中。
    • 当我们调用 epoll_wait 时,内核会返回所有在就绪队列中的文件描述符及其触发的事件类型。
  3. 事件处理:
    • 我们在 epoll_wait 返回的文件描述符列表中进行遍历,检查每个文件描述符的实际触发事件类型(例如 events[i].events & EPOLLIN)。
    • 根据触发的事件类型调用相应的回调函数进行处理。

总:这里有一个很重要的概念,就是绑定事件事件发生是两码事,
EPOLLIN事件是因为文件描述符上有可读数据产生的。
EPOLLOUT事件是因为文件描述符的缓冲区可写时产生的。
如果你绑定了EPOLLOUT,然后文件描述符的缓冲区又可写,同时你又没有数据要发送,那么会一直无效调用回调函数,浪费资源,所以绑定和解绑EPOLLOUT的时机很重要。

  • 12
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值