【Linux系统与网络编程】18:并发模型

并发模型


OVREVIEW

  • 并发模型
      • 一、并发模型
        • 1.多进程
        • 2.多线程
        • 3.单反应堆
        • 4.单反应堆&线程池
        • 5.主从反应堆
        • 6.主从反应堆&线程池
      • 二、案例实践
        • 1.使用epoll实现Echo服务器
        • 2.fix1
        • 3.fix2
        • 3.Q1
        • 4.Q2

一、并发模型

【Linux系统与网络编程】18:并发模型_第1张图片

1.多进程

多进程 + IO,1个连接对应1个进程的多进程模式,实现低并发:

【Linux系统与网络编程】18:并发模型_第2张图片

2.多线程

相较于多进程IO,多线程共享进程空间,线程的通信变得简单、成本低、但是需要考虑线程安全的问题(互斥、锁实现)

3.单反应堆

4.单反应堆&线程池

5.主从反应堆

6.主从反应堆&线程池

二、案例实践

1.使用epoll实现Echo服务器

利用并发模型4:单个反应堆 + 线程池的模式,实现一个支持高并发的小型的echo服务器。

反应堆模式(epoll)负责事件分发 + 线程池处理具体服务逻辑(echo服务):

  1. 要求服务器端有缓存机制:4096Byte
  2. 当收到某个特殊字符时 \n 时,将之前的信息进行统一处理:大写变小写小写变大写,之后回传给发送者。
  • 每一个连接会连续多次发送数据
  • 当客户端主动断开连接后,服务端也便断开连接

注:客户端保证在断开之前会发送完整的数据,且客户端一定会调用close()

#include "head.h"
#include "common.h"
#include "thread_pool.h"

#define QUEUESIZE 100//任务队列大小
#define INS 4//线程数量
#define MAXEVENTS 5//epoll_event的最大数量
#define MAXCLIENTS 2000//最大客户端数量为2000

int epollfd;
int clients[MAXCLIENTS];//每次出现新的套接字时便存储到该数组中 保证临时文件描述符fd在循环中被修改后 操作的对象也不会改变
pthread_mutex_t mutex[MAXCLIENTS];//对用户临时数据上锁

char *data[MAXCLIENTS];//线程进行具体业务操作时 存储每个客户端产生的临时数据

#define handle_error(msg) \
	do { perror(msg); exit(EXIT_FAILURE); } while (0)

void do_work(int fd) {
	/* 对fd文件进行的具体逻辑操作 */
	int rsize;
	char buff[4096] = {0};
	DBG(BLUE" : data is ready on %d.\n"NONE, fd);
	//1.数据接收recv
	if ((rsize = recv(fd, buff, sizeof(buff), 0)) < 0) {
		/* 如果接受数据出错 则使用epoll_ctl将fd文件描述符删除 */
		epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL);
		DBG(RED" : %d is close!\n"NONE, fd);
		close(fd);//检测到客户端关闭连接 服务端也关闭连接
		return;
	}
	//2.对数据进行简单的处理 并进行最后的send操作 
	int ind = strlen(data[fd]);
	pthread_mutex_lock(&mutex[fd]);
	for (int i = 0; i < rsize; ++i) {
		if (buff[i] >= 'A' && buff[i] <= 'Z') {
			data[fd][ind++] = buff[i] + 32;//大写转小写
		} else if (buff[i] >= 'a' && buff[i] <= 'z') {
			data[fd][ind++] = buff[i] - 32;//小写转大写
		} else {
			data[fd][ind++] = buff[i];//其他字符不处理
			if (buff[i] == '\n') {
				DBG(GREEN" : \\n recved!\n"NONE);
				send(fd, data[fd], ind, 0);
			}
		}
	}
	pthread_mutex_unlock(&mutex[fd]);
}

void *thread_run(void *arg) {
	pthread_detach(pthread_self());
	struct task_queue *taskQueue = (struct task_queue *)arg;
	while (1) {
		int *fd = task_queue_pop(taskQueue);
		do_work(*fd);
	}
}

int main(int argc, char *argv[]) {
	int opt;
	int port;
	while ((opt = getopt(argc, argv, "p:")) != -1) {
		switch (opt) {
			case 'p':
				port = atoi(optarg);
				break;
			default:
				fprintf(stderr, "Usage : %s -p port!\n", argv[0]);
				exit(1);
		}
	}
	DBG(YELLOW" : Server will listen on port [%d]\n"NONE, port);

	//1.创建监听套接字
	int server_listen;
	if ((server_listen = socket_create(port)) < 0) handle_error("socket_create");
	clients[server_listen] = server_listen;//文件描述符作为数组下标 存储新出现的listen_socket文件描述符
	DBG(YELLOW" : Server_listen starts.\n"NONE);

	//2.初始化任务队列和锁
	for (int i = 0; i < MAXCLIENTS; ++i) data[i] = (char *)calloc(1, 4096 + 10);//使用calloc在reacotr thread中开辟内存空间
	struct task_queue *taskQueue = (struct task_queue *)calloc(1, sizeof(struct task_queue));
	task_queue_init(taskQueue, QUEUESIZE);
	DBG(YELLOW" : task queue init successfully.\n"NONE);
	for (int i = 0; i < MAXCLIENTS; ++i) pthread_mutex_init(&mutex[i], NULL);
	DBG(YELLOW" : mutex init successfully.\n"NONE);

	//3.创建多个线程进行工作
	pthread_t *tid = (pthread_t *)calloc(INS, sizeof(pthread_t));
	for (int i = 0; i < INS; ++i) pthread_create(&tid[i], NULL, thread_run, (void *)taskQueue);
	DBG(YELLOW" : threads create successfully.\n"NONE);

	//4.利用反应堆模式epoll进行事件分发
	int sockfd;
	// 4-1 epoll_create
	if ((epollfd = epoll_create(1)) < 0) handle_error("epoll_create");//文件描述符被占用完就可能会出错

	// 4-2 epoll_ctl将server_listen文件描述符注册到epoll实例中
	struct epoll_event events[MAXEVENTS], ev;
	ev.data.fd = server_listen;//关注的文件描述符
	ev.events = EPOLLIN;//关注的事件、需要注册的事件
	if (epoll_ctl(epollfd, EPOLL_CTL_ADD, server_listen, &ev) < 0) handle_error("epoll_ctl");//注册操作
	DBG(YELLOW" : server_listen is added to epollfd successfully.\n"NONE);
	
	for (;;) {
		// 4-3 epoll_wait开始监听
		int nfds = epoll_wait(epollfd, events, MAXEVENTS, -1);//nfds epoll检测到事件发生的个数
		if (nfds == -1) handle_error("epoll_wait");//可能被时钟中断 or 其他问题

		for (int i = 0; i < nfds; ++i) {
			// 4-3-1 对epoll_wait监测到发生的所有事件进行遍历,进行事件分发
			int fd = events[i].data.fd;
			if (fd == server_listen && (events[i].events & EPOLLIN)) {
				/* 返回的fd为server_listen可读 表示已经有客户端进行3次握手了 */
				/* events[i].events & EPOLLIN 表示至少有一个可读 */
				// 4-3-2将accept到的文件描述符注册到epoll实例中,实现文件监听
				if ((sockfd = accept(server_listen, NULL, NULL)) < 0) handle_error("accept");
				//如果是实际应用情况,如果出现错误应该想办法处理错误,并恢复实际业务
				clients[sockfd] = sockfd;//文件描述符作为数组下标 存储新出现的conn_socket文件描述符
				ev.data.fd = sockfd;
				ev.events = EPOLLIN | EPOLLET;//设置为边缘触发模式
				make_nonblock(sockfd);
				if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev) < 0) handle_error("epoll_ctl");//注册操作
			} else {
				/* 返回的fd不是server_listen可读 是普通的套接字 */
				// 4-3-3 将监测到事件event的文件描述符fd加入任务队列中,交给线程池处理
				if (events[i].events & EPOLLIN) {
					/* 套接字属于就绪状态 有数据输入需要执行 */
					task_queue_push(taskQueue, (void *)&clients[fd]);
					//不可直接将fd传入 需要保证传入的fd值总是不同,创建clients[]数组保证每次传入的fd值不同
					//当把地址作为参数传递给函数后(特别是在循环中),下一次fd的值会不断的被修改(传入的值是会变化的)
				} else {
					/* 套接字不属于就绪状态出错 将该事件的文件描述符从注册的epoll实例中删除 */
					epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL);
					close(fd);
				}
			}
		}//for
	}//for
	return 0;
}
//common.h
#ifndef _COMMON_H
#define _COMMON_H

int socket_create(int port);
int socket_connect(const char *ip, int port);

int make_nonblock(int fd);
int make_block(int fd);

#endif
//common.c
#include "head.h"
#include "common.h"

int socket_create(int port) {
	int sockfd;
	struct sockaddr_in server;
	//1.创建套截字
	if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) return -1;

	//2.绑定套接字与结构体信息
	server.sin_family = AF_INET;//ipv4
	server.sin_port = htons(port);//host to net short 本地字节序的短整型转换为网络字节序
	server.sin_addr.s_addr = htonl(INADDR_ANY);//host to net short 本地字节序的长整型转换为网络字节序 INADDR_ANY监听任何一个地址 0.0.0.0
	int reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (char *)&reuse, sizeof(int));//服务端地址重用
	if (bind(sockfd, (struct sockaddr*)&server, sizeof(server)) < 0) return -1;

	//3.将主动套接字转为被动
	if (listen(sockfd, 20) < 0) return -1;// 20: backlog正在连接的连接序列有多少个
	return sockfd;
}

int socket_connect(const char *ip, int port) {
	int sockfd;
	//1.客户端打开一个socket
	if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) return -1;

	//2.定义结构体用于绑定端口号、ip地址(存放服务端的具体信息)
	struct sockaddr_in server;
	server.sin_family = AF_INET;
	server.sin_port = htons(port);//端口号
	server.sin_addr.s_addr = inet_addr(ip);//ip地址

	//3.建立连接connection
	if (connect(sockfd, (struct sockaddr *)&server, sizeof(server)) < 0) return -1;
	return sockfd;
}

int make_nonblock(int fd) {
	int flags;
	if ((flags = fcntl(fd, F_GETFD)) < 0) return -1;
	flags |= O_NONBLOCK;//为GF加上可爱属性
	if (fcntl(fd, F_SETFL, flags) < 0) return -1;
	return 0;
}

int make_block(int fd) {
	int flags;
	if ((flags = fcntl(fd, F_GETFD)) < 0) return -1;
	flags &= ~O_NONBLOCK;
	if (fcntl(fd, F_SETFL, flags) < 0) return -1;
	return 0;
}
//thread_pool.h
#ifndef _THREAD_POOL_H
#define _THREAD_POOL_H

#include "head.h"

struct task_queue {
	int head, tail;
	int size;//队列容量
	int count;//已经入队的元素
	void **data;//模拟任务
	pthread_mutex_t mutex;//需要互斥锁加锁
	pthread_cond_t cond;//信号量
};

void task_queue_init(struct task_queue *taskQueue, int size);//队列初始化
void task_queue_push(struct task_queue *taskQueue, void *data);//入队
void *task_queue_pop(struct task_queue *taskQueue);//出队

#endif
//thread_pool.c
#include "head.h"
#include "thread_pool.h"

void task_queue_init(struct task_queue *taskQueue, int size) {
	taskQueue->size = size;
	taskQueue->count = taskQueue->head = taskQueue->tail = 0;
	taskQueue->data = calloc(size, sizeof(void *));
	pthread_mutex_init(&taskQueue->mutex, NULL);
	pthread_cond_init(&taskQueue->cond, NULL);
}

void task_queue_push(struct task_queue *taskQueue, void *data) {
	/* 为了保证线程的安全性 所有对临界区的操作都需要加锁 */
	pthread_mutex_lock(&taskQueue->mutex);
	if (taskQueue->count == taskQueue->size) {
		DBG(YELLOW" : taskQueue is full\n"NONE);
		pthread_mutex_unlock(&taskQueue->mutex);
		return;
	}
	taskQueue->data[taskQueue->tail] = data;
	DBG(GREEN" : data is pushed!\n"NONE);
	taskQueue->tail++;
	taskQueue->count++;
	/* 考虑循环队列 */
	if (taskQueue->tail == taskQueue->size) {
		DBG(YELLOW" : taskQueue tail reach end!\n"NONE);
		taskQueue->tail = 0;
	}
	pthread_cond_signal(&taskQueue->cond);//信号量
	pthread_mutex_unlock(&taskQueue->mutex);
	return;
}

void *task_queue_pop(struct task_queue *taskQueue) {
	pthread_mutex_lock(&taskQueue->mutex);
	//使用while循环而不是if语句 处理惊群效应
	while (taskQueue->count == 0) {
		/* 当任务队列中没有任务时 线程选择等待而不是直接return
		1.如果让线程直接返回则意味着一会还需要让线程轮训回来(轮询时间有要求)
		2.轮询时间太短则消耗CPU
		3.轮询时间太长则相应能力下降
		 */
		pthread_cond_wait(&taskQueue->cond, &taskQueue->mutex);//cond与mutex同时使用
	}
	void *data = taskQueue->data[taskQueue->head];
	DBG(RED" : data is poped!\n"NONE);
	taskQueue->count--;
	taskQueue->head++;
	/* 考虑循环队列 */
	if (taskQueue->head == taskQueue->size) {
		DBG(YELLOW" : taskQueue head reach end!\n"NONE);
		taskQueue->head = 0;
	}
	pthread_mutex_unlock(&taskQueue->mutex);
	return data;
}

几个问题:

  1. 线程数量对性能的影响?
  2. 任务队列的大小对性能的影响?
  3. epoll使用边缘触发效率更高,还是使用水平触发效率更高?
  4. 在任务队列中队列满时,是将数据丢弃还是继续等待直到数据可以被加入队列。
  5. 互斥锁带来的性能下降,加锁直接导致效率下降,加锁操作是否有必要?能否优化到其他地方?

2.fix1

在任务队列满时,是将数据丢弃还是继续等待直到数据可以被加入队列?答案是显然的线程必须进行等待,不可将数据随意丢弃。

3.fix2

再次考虑操作4-3-3,当文件有events动作时,将监测到事件event的文件描述符fd加入任务队列中,

发现可能存在一种情况,当task_queue_push将events的文件描述符fd加入队列后,然后立刻发生了一个events事件需要将新的fd加入到任务队列中(导致同一个文件fd在任务队列中出现两次)

当在线程池中的线程在进行recv操作时,可能出现第一次recv操作直接将两次数据都给接收了,导致第二次recv操作返回-1(发生在do_work函数中的数据接收部分),

考虑到这种recv返回情况的多样性,需要对do_work函数中的recv返回值判断进行修改,不能直接使用epoll_ctl将fd文件描述符删除,而是需要进一步判断是否存在上述发生的情况(通过判定errno是否为EAGAIN来进行处理):

//1.数据接收recv
if ((rsize = recv(fd, buff, sizeof(buff), 0)) < 0) {
    if (errno != EAGAIN) {
        /* 如果接受数据出错 则使用epoll_ctl将fd文件描述符删除 */
        epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL);
        DBG(RED" : %d is close!\n"NONE, fd);
        close(fd);//检测到客户端关闭连接 服务端也关闭连接
    }
    return;
}

3.Q1

当任务队列中出现了1个文件描述符的4次事件(依次出现在任务队列中),线程池中的线程会依次拿到该文件描述符的4次事件(但是4个线程的执行先后顺序是不清楚的),

如果4次事件中的某次事件为文件读取操作,那么其读取到的数据到底是什么数据?是会读取到最后一次响应的数据,还是会读取到最先开始的数据。

【Linux系统与网络编程】18:并发模型_第3张图片

网络连接就是字节流,整个套接字网络数据就像一个水管,水管里面排的就是一个个字节。不论fd文件的events任务触发的顺序是如何的,当每次进行recv操作时,一定是从字节流的一端取出需要的数据。

4.Q2

多个线程并发的去处理数据时,会产生线程不安全性问题,难道对网络数据进行recv操作时,不会竞争吗?recv操作是线程安全的吗?

  1. recv是线程安全的,recv在底层进行read操作时不会同时执行,会保证操作的原子性。
  2. recv与send操作不用考虑并发所引起的竞争问题。

你可能感兴趣的:(#,Linux系统与网络编程,系统编程,网络编程,并发,线程,线程池)