Linux网络编程之socket(二):多线程、多线程BIO以及线程池BIO实现
- 由上一篇文章我们可以知道,如果使用单线程BIO来实现服务端和客户端的网络通信,会导致服务端同一时间只能处理(连接)一个客户端,而此时如果双方没有读写操作,就会由于阻塞而导致CPU资源的浪费。所以我们需要一种方法来使服务器能够“同时”处理更多的客户端,提高CPU利用率(多核CPU同一时刻只能运行一个进程<可以
并发的运行多个进程>,但是可以并行执行的线程数等于核心数)。所以我们本篇来实现多线程+BIO相关的socket网络通信。
一、多线程的实现
- 初识Linux下的多线程pthreads:
- 首先我们打开终端,输入:man pthreads查看pthreads的帮助手册
通过这个帮助手册,我们可以对POSIX THREADS有个初步的了解。这段话的意思是:
- POSIX.1指定了一组接口(函数和头文件)用于线程编程,它们通常被成为POSIX线程/Pthreads。一个进程可以包含多个线程,所有线程都在执行同一个程序。这些线程共享这个进程的全局内存(数据和堆空间),但每个线程都有自己的栈空间。
- POSIX.1还要求了,同一进程下的不同线程共享该进程的哪些属性、各自拥有什么属性。
- 接着手册往下看:
- Pthreads函数返回值:大多数pthreads函数在成功时返回0 ,失败时返回一个错误代码。请注意,pthreads函数不设置errno(对比前面的socket和TCP相关函数)。
- 线程ID:进程中每个线程都有一个唯一的线程标识符(用一个pthread_t类型的变量存储),这个变量有两个人可以获得。一个是使用pthread_create()创建该线程的调用者,另一个是这个线程自己。线程ID只保证在该进程中是唯一的,对那些接受线程ID作为参数的pthreads函数而言,线程ID告诉它们的是与调用者在同一进程中的那个线程。
- 线程安全函数:线程安全函可以在被同多线程同时调用时保证安全,即它将提供相同的结果,无论这个结果是什么。
- 好的,现在我们对pthreads有了初步的了解,那接下来我们就看看如何创建和安全的使用它。
- 首先我们打开终端,输入:man pthreads查看pthreads的帮助手册
- 如何创建一个线程并使其正确退出:
-
创建线程:int pthread_create(pthread_t *thread,pthread_attr_t *attr,void *(*start_routine)(void *),void *arg)
- 通过这个函数,我们就可以在主线程中创建子线程并运行,我们来逐一了解这个函数中的参数:
- pthread_t *thread:创建线程,成功返回0,错误返回一个错误代码。该函数会为被创建的线程分配一个唯一标识它的ID,这个ID被存储在thread所指的内存中,调用这个函数创建线程的caller和线程本身都可以获得这个ID。pthread_t是unsigned long。
- pthread_attr_t *attr:attr参数用于设置线程的属性。传入NULL表示使用默认线程属性。
- void *(*start_routine)(void *):start_routine是一个函数指针,它指向这个线程要运行的函数。该指针所指函数的返回值是void*类型,接受一个void*类型的参数(可以省略不写)。所以
传入实参时一定是传入函数名,而不是函数名()! - void *arg:指向要传给start_routine函数的实参的指针。如果不需要传递参数,就传入NULL。
-
在介绍完如何创建一个线程后,我们来实现一个简单的多线程程序:
#include <pthread.h> #include <unistd.h> #include <stdio.h> void* myturn(void* args){ for(int i=0;i<10;++i){ printf("My turn\n"); sleep(1); } return NULL; } void yourturn(){ for(int i=0;i<3;++i){ printf("Your turn\n"); sleep(2); } } int main(){ pthread_t newthread; pthread_create(&newthread,NULL,myturn,NULL); //myturn(); //sleep(1); //pthread_join(newthread,NULL); yourturn();//主线程比子线程先执行? 答:主线程总是优于子线程的执行,多线程中,子线程的执行是在主线成没有等待/占用的情况下 //使主线程执行完之后,等待指定的子线程结束再停止 //pthread_join(newthread,NULL); //pthread_exit(NULL); return 0; 【子线程只能在主线程不占用/等待时,使用CPU】 。/加上进程退出函数/主线程和子线程的先后执行顺序,用先运行yourturn和slepp(1)换位置两个方法 }这个例程中,我们实现了两个函数,它们分别按自己的时间间隔多次打印。然后我们在主线程中,创建一个线程并传入myturn函数,然后在主线程中运行yourturn,然后观察输出:
我们发现,子线程运行的myturn函数并没有执行完,整个进程就结束了。因为main线程的return时隐式地调用了exit()结束进程。而这个未执行完的子线程就成了
僵尸进程(僵尸进程是一个已经死亡的进程,它不再运行,也不能被调用,也几乎不占用内存,但是它会占用一个进程ID直至系统重启)。此时我们就需要一种方法来使主线程等待子线程运行完成后再exit()。将上面代码片段中的pthread_join(newthread,NULL)取消注释,然后再次运行程序。观察打印结果我们发现子进程可以完整执行,那pthread_join做了什么呢?我们来介绍关于结束线程的一些函数。
-
结束线程:
- 当发生以下情况,线程就会结束(释放线程ID):
- 线程运行的函数return了,即线程已经执行完成
- 线程自己调用了pthreads_exit()
- 其他线程调用了pthread_cancel()来结束我这个进程
- 线程自己调用exec()或exit()
- main线程比子线程先运行结束,却没在return/exit前调用pthreads_exit()/pthreads_join()来等待子线程运行完成。
- 我们来看一下上述的3个结束线程的函数:
- pthread_exit(void* retval):该函数用于当前线程的退出,并将返回值(进程状态)存入retval所指的内存中(可以不返回,传入NULL即可)。所以一般是
子线程在结束时调用这个函数,以确保自己安全、干净地退出(在主线程中调用该函数,会等待所有子线程执行完成)。 - pthread_join(pthread_t thread,void** retval):这个函数类似于回收进程的wait和waitpid,即该函数用于线程回收。
同一进程中的所有线程都可以调用pthread_join函数来回收其他线程,即如果一个线程执行该函数,就会在该函数的执行处阻塞、挂起,将CPU资源让给该子线程,直到子线程执行完成。在这种方式下被等待的子线程在执行完后资源会自动释放。需要注意的是,一个线程只能被一个线程等待,否则其他调用pthread_join的线程都会收到ESRCH错误代码。所以一般是主线程中调用pthread_join等待子线程执行结束,子线程在结束前调用pthread_exit来安全的退出自己并返回状态给pthread_join的指针,使该子线程的caller能获得该子线程的退出状态。 - pthread_cancel(pthread_t thread):同一进程中的所有线程,可以通过该函数来异常终止另一个线程(即取消该线程),不过接收到取消请求的目标线程可以自己决定是否允许被取消,分别由pthread_setcancelstate和pthread_setcanceltype着两个函数来完成。
- pthread_exit(void* retval):该函数用于当前线程的退出,并将返回值(进程状态)存入retval所指的内存中(可以不返回,传入NULL即可)。所以一般是
- 当发生以下情况,线程就会结束(释放线程ID):
-
加上线程退出函数的完整代码:修改上面的代码片段,加上线程结束函数,并观察打印结果。
#include <pthread.h> #include <unistd.h> #include <stdio.h> void* myturn(void* args){ for(int i=0;i<10;++i){ printf("My turn\n"); sleep(1); } pthread_exit((void*)"i'm over\n"); return NULL; } void yourturn(){ for(int i=0;i<3;++i){ printf("Your turn\n"); sleep(2); } //pthread_exit(NULL); } int main(){ pthread_t newthread; pthread_create(&newthread,NULL,myturn,NULL); yourturn();//主线程比子线程先执行? 答:主线程总是优于子线程的执行,多线程中,子线程的执行是在主线成没有等待/占用的情况下 //使主线程执行完之后,等待指定的子线程结束再停止 char * state_buf[256]; pthread_join(newthread,(void **)&state_buf); printf("Here is main,%ld say:%s\n",newthread,*state_buf); return 0; }在yourturn()中分别加上pthread_exit()和注释掉pthread_exit(),再观察打印结果,我们可以发现如果在your_turn中加上pthread_exit()则子线程能够运行完成,但是printf()没运行,如果注释掉pthread_exit()则程序都可以运行完成。这是为什么呢?
- 【主线程执行pthread_exit()】 根据POSIX定义,
如果你只想杀死主线程同时保持其他所有子线程的运行状态,那你需要在主线程return/exit()之前,执行pthread_exit()。因为这样主线程就会直接结束,并且子线程仍在运行(成为孤儿进程),所以这种情况下,主线程在your_turn()结束时就被杀死了,没有继续往下运行printf、pthread_join、return的机会。而子线程由Init接管,直至其运行完成,再由Init完成线程收集任务。所以打印结果就会呈现为上面一段那样,在第三个Your turn打印完后,主线程已经死掉了。后续是子线程在Init的接管下运行,所以运行完自然不会有printf的打印结果。 - 【主线程执行pthread_join()】 如果在your_turn()中注释掉pthread_exit(),则主线程会在pthread_join处阻塞、挂起,将CPU资源全让给子线程执行,然后在子线程执行完之后,收集子线程(释放子线程资源),并获得子线程通过pthread_exit(void* retval)存入指针所指内存的内容。然后再return结束进程。所以运行完的打印结果是我们预期的那样。这里要注意的就是子进程返回值的类型和主进程中如何接受该返回值。
因为子线程必须返回void*类型的指针,所以我们要将想要返回的指针(字符串名就是指向首地址的指针)强制转换为void*类型。那主线程这边如何接受这个void*类型的指针的?pthread_join函数接受一个指向指针的指针,即传入的实参要能被这个返回值赋值,所以我们定义一个数组,然后取地址传入实参(数组名的地址就是指向指针的指针),然后用这个实参来指向pthread_exit()中的指针retval,即&state_buf=&p,然后再将其转为void*,就能够通过state_buf读到数据了。子线程的返回: pthread_exit((void*)"i'm over\n"); 主线程的接受: char * state_buf[256]; pthread_join(newthread,(void **)&state_buf); - 一些补充:
exit()是使当前进程结束(在线程中调用则使其所在进程结束),pthread_exit()是使当前线程结束,但如果这个函数用于主线程,那主线程就会在此结束,但此时整个进程不会结束,其他的线程会交给Init来管理直至执行完成。
- 【主线程执行pthread_exit()】 根据POSIX定义,
-
关于线程的执行顺序:可以看到,第一份代码中有关于sleep()的不同位置的代码,那是因为一开始将sleep()放在函数的开头时,在多次运行中发现,每次执行的第一个函数是不同的,有时是子线程先执行,有时是主线程先执行,于是便做了一些测试,通过调整sleep()来使主线程先抢到cpu资源。实际上,
在Linux中,主线程和子线程是同级别的,谁先抢到cpu就谁先执行,但是一般而言主线程会先执行(在上面这种情况下),因为主线程创建完子线程就可以接着往下走了,此时子线程还在初始化等,所以在这种情况下,主线程会先抢到cpu资源。
-
- 如何保证多线程安全的访问共享资源:互斥锁
-
多线程帮我们提高了CPU的利用率,但同时带来了很大的资源安全问题,我们通过下面这个例子来了解一下:
在这段代码中,我们实现了一个累加函数,然后在主函数中调用它两次,然后输出累加结果。如果我们使用多线程来同时执行这个函数两次会发生什么呢?
可以发现,使用多线程来执行相同的功能,答案是错误的,并且每次输出的结果都不一样。这是因为自增运算符并不是
原子操作:++ 在系统中分为3步执行,从内存中取出i到cpu的寄存器中,然后cpu计算加1,然后将结果再存回内存中。那此时如果第一个线程取出数在计算时,第二个数也取出了计算前的数,那它们的计算就是重复的,即两个线程都对同一个数加1并存回内存,这样的一次重复就导致了count少被+1,从而导致了错误的结果。而这样的线程是不安全的,我们需要某些方法来使其线程安全(即使对公共资源的操作变为原子操作)。 -
互斥锁:Mutex Locks(Mutual exclusion locks)是一种计算抽象,它使一个线程在访问公共资源时,其他线程就无法访问(
锁是内核对象,同一时间只有一个线程能获得锁)。我们修改上面的代码,为count++这个操作加锁,使其成为一个“原子操作”,然后观察结果,结果正确。但是程序真的会慢很多!慢的我一度以为电脑卡住了(就是Ctrl+c想关闭程序那里)。
-
到这里我们就了解完了最基础的多线程知识,接下来我们来实现多线程的BIO。
-
二、多线程+BIO的实现
- 这一节我们使用上面讲到的基础多线程知识,结合两个例子来学习多线程+BIO的网络模型。
-
首先结合第一篇文章中的单线程BIO程序,我们实现其多线程版本:
- 因为是服务端并发处理多个客户端,所以客户端的实现还和单线程BIO中的一样即可,我们只需要修改单线程BIO中的服务端代码,使其可以多线程并发处理客户端请求。服务端的代码实现如下:
#include <sys/socket.h> #include <sys/types.h> #include <sys/un.h> #include <netinet/in.h> // sockaddr_in 定义在<netinet/in.h>或<arpa/inet.h>中,该结构体解决了sockaddr(定义在<sys/socket.h> 里)的缺陷,把port和addr分开存储在两个变量中 #include <arpa/inet.h> // inet_addr #include <unistd.h> //socklen_t #include <stdio.h> #include <string.h> #include <stdlib.h> #include <pthread.h> #include <errno.h> #define seraddr "192.168.2.128" //服务器的ip(点分十进制字符串表示) //#define seraddr "0.0.0.0" //表示任意网卡都可以和这个ip绑定 主机网卡对应的ip:127.0.0.1 #define serport 1234 //服务器的port #define BACKLOG 5 //处于 完全连接状态的socket 的上限 //char rec_buf[1024]; int client_num=0; void* handle_client(void* fd){ //接受 连接描述符,实现与客户端的具体通信 int *connectedfd=(int *)fd; //获取客户端地址 struct sockaddr_in connectedfd_remote_addr; socklen_t connectedfd_remote_addr_len=sizeof(struct sockaddr_in); int ret=getpeername(*connectedfd,(struct sockaddr*)&connectedfd_remote_addr,&connectedfd_remote_addr_len); printf("current connectedfd:%d,current has %d client connected\n",*connectedfd,client_num);//用于和当前客户端通信的文件,是该进程打开的第几个文件 printf("client_addr:%s %d\n",inet_ntoa(connectedfd_remote_addr.sin_addr),ntohs(connectedfd_remote_addr.sin_port));//[这里的client ip输出是0.0.0.0表示,被同意连接的客户端可以是本机上的任意一个进程(port)] //读取客户端的数据,打印并发回 char rec_buf[1024]; //memset(rec_buf,0,sizeof(rec_buf)); while(1){ memset(rec_buf,0,sizeof(rec_buf)); int n_read=recv(*connectedfd,rec_buf,sizeof(rec_buf),0); //n_read<=rec_buf.size() 如果发送的数据大于这个rec_buf,则recv函数多次进入接受缓冲区分批放入该数组 返回读到的数据长度(可能小于期望长度,因为可能Buf太小,一次读不完) if(n_read>1){ printf("len:%d client's msg:%s\n",n_read,rec_buf);//从连接套接字指向的文件中 读出客户端发过来的消息 send(*connectedfd,rec_buf,sizeof(rec_buf),0); } else{ printf("%d\n",n_read); printf("client disconnect\n"); break; //ctrl+c 断开客户端 } } --client_num; printf("connectedfd:%d has closed,current hase %d client connected\n",*connectedfd,client_num); close(*connectedfd);//关闭当前这个进程负责的通信套接字 free(connectedfd);//释放内存 pthread_exit(NULL); } int main(int argc,char **argv){ //定义一些需要用到的变量/对象 int listenfd=-1; //server's listening socket struct sockaddr_in s_addr={0}; //server's socket address //1.创建socket描述符 listenfd=socket(AF_INET,SOCK_STREAM,0); printf("listenfd:%d\n",listenfd); //2.命名socket(将socket描述符绑定到一个socket地址上)----->给接受连接申请的文件搞个名字(即在文件表中的索引) 本机:192.168.2.128 s_addr.sin_family=AF_INET; //地址族(要和socket定义时一样) s_addr.sin_addr.s_addr=inet_addr(seraddr); //将 点分十进制字符串表示的ipv4地址 转化为 网络字节序证书表示的ipv4地址 s_addr.sin_port=htons(serport); //将 无符号短整形的主机字节序 转化为 短整形的网络字节序 //把listenfd绑定到要监听的端口上----->感觉理解为s_addr这个进程打开一个文件用于接受连接请求,然后返回该文件在文件表中的索引,该索引值就是listenfd比较好(不然到下面理解成两个插座绑定在一个进程上很难受) int ret=bind(listenfd,(struct sockaddr *)&s_addr,sizeof(struct sockaddr)); //好的,从现在开始,我们就可以通过listenfd这个索引去服务端进程的文件表中找到用于接受连接请求的文件 if(ret==-1){ perror("bind"); //配合errno全局变量,打印一个系统错误信息 exit(-1); } //3.监听socket listen(listenfd,BACKLOG); //创建监听队列,以存放待处理的客户端链接 //4.主线程负责监听和分发连接给子线程 //int client_num=0; pthread_t thread_id=0; struct sockaddr_in c_addr={0}; //client's socket address socklen_t c_addrlen=sizeof(c_addr); //client's sockaddr's length //int* connectedfd=(int *)malloc(sizeof(int)); while(1){ //定义一个指向connectedfd的指针(因为pthread_create需要传入的是指向connectedfd的指针,而不是connectedfd本身) int* connectedfd=(int *)malloc(sizeof(int));//申请一个Int大小的原始内存---->堆内存,用完要手动释放,否则内存泄漏 *connectedfd=accept(listenfd,(struct sockaddr *)&c_addr,&c_addrlen); if(*connectedfd==-1){ perror("accept"); //配合errno全局变量,打印一个系统错误信息 exit(-1); } //创建子线程处理这个connectedfd if((pthread_create(&thread_id,NULL,handle_client,(void *)connectedfd))!=-1){ ++client_num; printf("\npthread create success,thread id is:%ld\n",thread_id); } else printf("pthread create fail\n"); } close(listenfd); return 0;//主控线程,不需要pthread_exit来终止自己,因为它有return,从main函数return,相当与调用exit() } - 首先我们看主函数,分析该版本与单线程BIO之间的区别,可以看到在socket通信的前3步中(创建socket-->绑定到服务器地址-->监听),两种方法的执行步骤是一样的。区别在于服务端和客户端连接成功后(即accept返回了connectfd后),单线程BIO模型下直接在主线程中执行对connectfd相关的操作(阻塞读、写),而多线程BIO模型则是只在accept()处阻塞,每成功连接一个客户端后(即accept返回一个connectfd),就创建一个子线程,让子线程去执行connectfd相关的操作,然后主线程继续在accept()处继续阻塞等待新的客户端连接。即以下代码:
在进入循环等待客户端的连接前,我们先创建线程ID(用于接收被pthread_create()创建成功的线程ID,每个线程ID唯一的标识了一个被创建的线程)。在进入循环后,我们先创建一个指向连接描述符的指针用于接收accept的返回值(因为要接收多个客户端,所以在每次等待客户端连接时,都要开辟一块sizeof(int)大小的内存来用于存放对即将连接的客户端的connectfd,
这里不能创建变量,否则存在内存泄露,指针作实参是传址,变量作实参是拷贝)。接着程序就阻塞等待客户端的连接,一旦有客户端连接成功,就通过pthread_create()来创建一个子线程,使这个子线程调用handle_client()函数来处理这对连接,然后主线程在创建完子线程之后就可以继续往下执行,然后重新阻塞在accept()处等待新的客户端连接。这就是多线程BIO模型的主线程实现逻辑。接下来我们看一下handle_client()函数,看每个子线程是如何实现和客户端的通信的。因为主线程中,将指向连接描述符的int*型指针转化成了void*类型传入函数(即赋值给这个fd),所以我们在该函数中首先要强其恢复为int*类型的指针(函数用void*来接收就是为了能让用户传入不同的类型,使用时强制转换回来即可)。然后可以发现该函数的内部实现其实就和单线程BIO模型的while内部实现相同。接收到描述符后在recv()处阻塞等待客户端的消息,然后再将其发回。如果客户端关断开了,那这边这个子线程就断开。整个handle_client()函数的逻辑还是比较简单的,但是需要注意以下几点:
- 释放connectfd指针:因为我们为每个客户端都分配了一块sizeof(int)的内存来存放服务端用于和当前客户端连接的connecfd,所以在当前连接断开后,我们需要
调用free()来释放这块内存,避免内存泄露。 - 安全退出当前子线程:线程资源是有限的,我们在使用完线程后必须释放其资源(特别是线程ID),所以我们应该在子线程结束前调用pthread_exit()函数来结束该子线程。
- 每次recv()阻塞等待前都要初始化rec_buf:如果没有每次执行前初始化rec_buf,则新的数据是在旧数据上覆盖,所以如果新的数据长度小于旧的数据,那数据旧出错了。
- 释放connectfd指针:因为我们为每个客户端都分配了一块sizeof(int)的内存来存放服务端用于和当前客户端连接的connecfd,所以在当前连接断开后,我们需要
- 最后我们来看一下该服务端并发处理客户端的运行结果和端口状态:
首先运行客户端,使其在accept()处阻塞,等待客户端连接。
接着我们运行一个客户端去连接服务端,然后观察服务端端口此时的网络连接状态,可以看到和终端打印的结果一样,服务端当前有一个连接,占用端口58812.此时服务单的子线程阻塞在recv()处等待这个客户端的数据。此时我们再开一个客户端去连接服务端,看看会怎么样:
可以看到,服务端此时开了两个子线程分别处理这两个连接,然后主线程又进入一次循环继续阻塞在accept()上等待新的客户端连接,由于多线程机制,我们使服务端成功的连接上了多个客户端。接下来,我们分别让两个客户端交替的发数据给服务端,看看服务端返回的数据有没有问题:
可以看到,服务端能够正确的将不同客户端的数据发回给对方。最后我们依次断开两个客户端,观察服务端的打印结果和断口状态:
观察打印结果和端口状态,当关闭一个客户端后,服务端也相应的关闭了子线程中的连接,此时客户端仅剩一个完全连接状态下的客户端,再使用该客户端发送一条消息,服务端仍能正确发回,此时再将第二个客户端关闭,观察端口状态,此时服务端以及没有完全连接状态的客户端,但服务端程序仍未结束,因为它仍在新的一轮循环中阻塞在accept()处等待新的连接。至此,在第一篇文章中程序的基础上实现的多线程版本例程我们就实现完了,并且程序能够正确运行,但一般的服务端程序并不是这样的任务,而是客户端发送一个请求,服务端查询后将结果返回,所以我们再看一个例子,进一步了解服务端的工作机制。
- 因为是服务端并发处理多个客户端,所以客户端的实现还和单线程BIO中的一样即可,我们只需要修改单线程BIO中的服务端代码,使其可以多线程并发处理客户端请求。服务端的代码实现如下:
-
简易的WebServer模拟:
-
这一块是学习了YouTube上一位老师的视频,他分别实现了一个客户端来访问google.com以获取首页html、一个单线程BIO的服务端来接收浏览器的访问以及一个多线程的服务端来接收客户端的文件读取请求(仿我们平时浏览器访问服务器的资源请求过程,去掉了HTTP协议以简单化),并发回目标文件中的数据。这里我将这两个客户端程序和多线程BIO的服务端程序整合成一个对应的服务端和客户端,来实现客户端对服务端中文件的请求,以及服务端将查询结果的发回。
-
视频连接如下,个人感觉这位老师的视频非常好,简短又直观,去掉了网络通信过程中的复杂上层应用协议,很好的把传输层的部分抽象出来了。 代码风格也很好,非常值得学习。 www.youtube.com/c/JacobSorb…
-
首先我们来看一下它客户端的实现,以及它是如何向网站发送请求,获取首页的html文件的:
#include <sys/socket.h> #include <sys/types.h> #include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <stdarg.h> #include <errno.h> #include <fcntl.h> #include <sys/time.h> #include <sys/ioctl.h> #include <netdb.h> #define server_ip "220.181.38.148" #define server_port 80 //HTTP standard port //#define server_port 8989 #define buf_size 4096 //char recv_buf[buf_size] //buf to get data back in #define SA struct sockaddr //为了让代码简洁点 //Name:error die function //Function:print out error messages and then exit the program ---------->不要使用errno void err_n_die(const char *fmt,...){ int errno_save; va_list ap; //any system or library call can set errno,so we need to save it now errno_save=errno; //print out the fmt+args to standard out va_start(ap,fmt); vfprintf(stdout,fmt,ap); fprintf(stdout,"\n"); fflush(stdout); //print out error message is errno was set if(errno_save!=0){ fprintf(stdout,"(errno=%d):%s\n",errno_save,strerror(errno_save)); fprintf(stdout,"\n"); fflush(stdout); } va_end(ap); //this is the ...and_die part .Terminate with an error exit(1); } int main(int argc,char **argv){ //int main(){ //local var char sendbuf[buf_size]={0}; char recvbuf[buf_size]={0}; //make sure person use the program right and specifically if(argc!=2) err_n_die("usage: %s <server address>\n",argv[0]); //1. create socket and return socketfd int connectedfd=-1; if((connectedfd=socket(AF_INET,SOCK_STREAM,0))<0) err_n_die("error while creating the socket!\n"); //2. initialize the connect target's socket address struct sockaddr_in server_addr={0}; server_addr.sin_family=AF_INET; server_addr.sin_port=htons(server_port);// host to network,short //server_addr.sin_addr.s_addr=inet_addr(server_ip); //inet_pton():如果函数出错返回一个负值,并设置errno为EAFNOSUPPORT,如果函数参数af指定的地址族和src格式不对,则返回0 if(inet_pton(AF_INET,argv[1],&server_addr.sin_addr)<=0) err_n_die("inet_pton error for %s\n",argv[1]); // "1.2.3.4" ---> [1,2,3,4] 把字符串类型的转为二进制网络地址格式 //3. connect to server and return client's connectfd if((connect(connectedfd,(struct sockaddr *) &server_addr,sizeof(struct sockaddr_in))<0)) err_n_die("connect failed!\n"); //4.HTTP //4.1 we're connected.prepare the message sprintf(sendbuf,"GET / HTTP/1.1\r\n\r\n"); //get command: give me a page /:i want to your route home page HTTP:tell server using HTTP \r\n\r\n:end of the request int sendbyte_len=strlen(sendbuf); //4.1 send request to server to read file context //sprintf(sendbuf,"/home/zwk/MyRepos/Cproject/MySocket/JacobSorber/WebServer/context\n"); //int sendbyte_len=strlen(sendbuf); //4.2 send the request -- making sure you send it all // This code is a bit fragile,since it bails if only some of the bytes are sent. // normally,you would want to retry,unless the return value was -1 //把数据写入sockfd所指向的socket,它就会把数据发给远端! if((send(connectedfd,sendbuf,sendbyte_len,0)!=sendbyte_len)) err_n_die("Incomplete request send,please retry\n");//ERROR:sizeof和strlen的返回值不同! //get the response of resquest int recvbyte_len=0; while(1){ memset(recvbuf,0,buf_size);//每次都要初始化,否则如果当前消息短于上一条 就会把未覆盖的部分输出出来 if((recvbyte_len=recv(connectedfd,recvbuf,sizeof(recvbuf)-1,0))>0){ printf("we get the html of %s\n",recvbuf); } else break; } if(recvbyte_len<0) err_n_die("recv error\n"); close(connectedfd); exit(0); }- 客户端程序和我们之前的实现流程是一样的,但是它为函数错误的处理实现了一个函数err_n_die(),使代码更简洁并减少很多变量的命名,这里最关键的是客户端发送的数据
sprintf(sendbuf,"GET / HTTP/1.1\r\n\r\n");,与我们之前随便发一个字符串不同,这边发送的是一条HTTP指令,GET表示向服务端请求(要)一个页面,/表示请求的文件是服务器的root home page(首页),HTTP1.1表示使用版本1.1的HTTP协议,最后\r\n\r\n是HTTP协议的结束符。所以我们就可以知道,原来这样使用socket我们就可以使用HTTP协议来访问服务器以请求一些资源。接下来我们ping以下百度获取它的ip,然后运行客户端程序,看能否成功获取它的首页html文件内容:可以看到,我们通过向220.181.38.251 80发送上面这条GET请求,成功的接收到了服务端发回的首页html文件内容。这里还有一个比较有趣的实验现象,当我们获取完html后,我们的程序其实是阻塞在recv()这里的,但此时客户端和服务端都不会再进行操作了,于是90s之后客户端程序发生了以下错误。
(errno=104):Connection reset by peer,就是说在90s未进行操作后,我们的客户端被百度reset了。可以大概猜到百度的网络设计是默认保持TCP连接时间为90s,90s内如果双方没有操作就reset客户端以释放资源,这样就避免了服务端的资源不被无效的连接占用。此时如果客户端想保持长连接就要每隔一段时间发送一次心跳协议来维持双方的连接。
- 客户端程序和我们之前的实现流程是一样的,但是它为函数错误的处理实现了一个函数err_n_die(),使代码更简洁并减少很多变量的命名,这里最关键的是客户端发送的数据
-
接着我们来看一下它服务端的实现:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdbool.h> #include <limits.h> #include <pthread.h> //#define server_ip "192.168.2.128" #define server_port 8989 #define buf_size 4096 #define SOCKETERROR (-1) #define server_backlog 1 typedef struct sockaddr_in SA_IN; typedef struct sockaddr SA; //void handle_connection(int client_socket);//单线程用这个 void* handle_connection(void* p_connect_fd); int check(int exp,const char *msg);//错误处理函数,因为很多函数错误时都是返回-1,所以可以合并这些处理操作 int main(int argc,char **argv){ int listen_fd, connect_fd, addr_size; SA_IN listen_addr,connect_addr; check((listen_fd=socket(AF_INET,SOCK_STREAM,0)),"Failed to create socket\n");//检测出入的addr能否监听(未被占用) //initialize the address struct listen_addr.sin_family=AF_INET; listen_addr.sin_addr.s_addr=INADDR_ANY;//0.0.0.0 listen_addr.sin_port=htons(server_port); //bind socket to address check(bind(listen_fd,(SA*)&listen_addr,sizeof(listen_addr)),"Bind Failed!\n"); check(listen(listen_fd,server_backlog),"Listen Failed\n"); //接受连接,并把建立好的连接交给handle_connection()去处理(读写) while(true){ printf("Waiting for connections...\n"); addr_size=sizeof(SA_IN); check(connect_fd=accept(listen_fd,(SA*)&connect_addr,(socklen_t*)&addr_size),"accept fail\n"); printf("Connected!\n"); //do whatever wo do with connections //单线程处理 //handle_connection(connect_fd); //多线程处理 pthread_t t; int *pconnectfd=malloc(sizeof(int)); *pconnectfd=connect_fd; pthread_create(&t,NULL,handle_connection,pconnectfd);//NOTE:void*可以接受Int* 会强制转换,所以在函数中使用时,需要再转回去! } return 0; } //客户端发一个文件名给服务端,服务端将这个文件中的内容发回给客户端(这其实和浏览器向服务器要数据是一样的,只不过没有HTTP协议) //void handle_connection(int connect_fd){ void* handle_connection(void* p_connect_fd){ int connect_fd=*((int*)p_connect_fd); free(p_connect_fd); char buffer[buf_size]; size_t byte_read; int msgsize=0; char actualpath[PATH_MAX+1]; //read the client's message:the name of the file to read while((byte_read=read(connect_fd,buffer+msgsize,sizeof(buffer)-msgsize-1))>0){ msgsize+=byte_read; if(msgsize>buf_size-1||buffer[msgsize-1]=='\n') break;//读客户端发过来的文件名,直到读到换行符 } check(byte_read,"recv error"); buffer[msgsize-1]=0; printf("REQUEST:%s\n",buffer);//打印客户端的请求,即他要查的文件名 fflush(stdout); //validity check if(realpath(buffer,actualpath)==NULL){ //检查文件名是否有效 printf("ERROR(bad path):%s\n",buffer); close(connect_fd); //return; 单线程 return NULL; } //read file and send its contents to client FILE *fp=fopen(actualpath,"r"); if(fp==NULL){ printf("ERROR(open):%s\n",buffer); close(connect_fd); //return; 单线程 return NULL; } //read file contects and send them to client //note this a fine example program,but rather insecure. //a real program would probably limit the client to certain files while((byte_read=fread(buffer,1,buf_size,fp))>0){ printf("sending %zu bytes\n",byte_read); write(connect_fd,buffer,byte_read); } close(connect_fd); fclose(fp); printf("closing connection\n"); return NULL; } int check(int exp,const char *msg){ if(exp==SOCKETERROR){ perror(msg); exit(1); } return exp; }可以看到,该服务端程序由3个部分组成:主函数(主线程)、错误检查处理函数、子线程的入口函数。主线程负责监听服务端端口,然后将建立好的连接分发给子线程去调用入口函数处理。这里我们可以学到
服务端程序收到客户端的文件内容请求时,需要做哪些方面的检查以及如何保证数据的读取完整。- handle_connection函数可以分为以下几步:
- 读取客户端的完整请求:因为客户端的请求内容可能很长,一次性读不完所以使用一个while和msgsize来完整的接收这个请求。
- 检查请求的文件路径是否正确以及文件能否打开
- 将文件中的数据通过多次send()来完整发送给客户端
- handle_connection函数可以分为以下几步:
-
最后我们修改客户端程序中的服务端地址为服务端定义的地址,修改sendbuf中的内容为服务端中可以请求的资源文件路径,然后运行服务端和客户端程序,查看测试结果:
可以看到,我们在context文件中随便复制了几行代码,使用客户端向服务端请求该文件夹中的context文件后,服务端成功的返回了该文件夹中的内容给客户端,这便是我们使用浏览器向某个url所对应的服务器中申请资源的运输层过程。
-
-
总结:至此,我们就用两个例子实现了多线程+BIO的网络模型,我们来总结以下该模型的优缺点:
- 优点:
- 充分利用了CPU资源,避免了单线程中CPU无效等待导致的资源浪费。
- 充分的利用了多核CPU,实现多线程的并行处理。
- 缺点:
- 一个客户端申请,就要开一个线程,使用完再销毁,开销很大(因为Linux中,线程就是轻量级的进程,所以它的创建和销毁成本很高)。
- 和单线程一样,当某一个线程的客户端不进行操作的适合,那个线程就挂在那边,导致了不必要的开销。
- 多线程并发运行时,线程间切换的成本很高。
- 多线程可能会导致线程安全问题。
- 由于要为每个客户端都开一个线程,所以没有办法应对连接量很大的场景(高并发)。
- 所以多线程BIO只适合连接量少且连接较长的场景,不适用于高并发场景。
- 优点:
三、线程池BIO的实现
- 多线程最大的问题:如果我们有成千上万的连接,那我们的服务端会为每个客户端创建一个线程。这会占用大量内存并可能使服务器性能变得很差(和线程数负相关)甚至系统崩溃,所以我们需要改进一下多线程的实现以提高服务器的性能,那本节我们就来学习一下如何通过线程池来解决多线程的这个最大弊端。
- 线程池:相较于为每个客户端创建一个线程,线程池很简单,在一开始我们创建一定量的线程,它们
挂起(这一块感觉是比较难理解的,因为创建线程后线程肯定就开始无限循环跑入口函数了,所以需要信号量来使其挂起/睡眠,本节学习的例程中并未实现这个步骤,只是单纯的让所有线程在那无限循环访问队列,这种实现模式是太吃资源了,所以实际中不可能这么实现)在那边随时可用,当有客户端连接时,我们将其交给一个线程(从代码的实现来看,说是线程抢到了这个connectfd更为合适!)。如果此时线程池中没有可用的线程了,那客户端就要在那等待。这样我们既保证了多个线程运行任务,又避免了创建无数的线程影响服务器性能。所以线程池带来了两大优点:- 控制线程的数量
- 降低线程创建和销毁的成本
- 线程池的数量取决于你的硬件,你可以给这个程序多大内存以及服务器将要承受的工作负载。
- 线程池BIO实现的服务端代码
- 我们修改上面的多线程代码,在里面加入线程池和任务队列,代码实现如下:
- myqueue.h和myqueue.c:
从头文件可以看出,我们使用链表来实现一个任务队列(【myqueue.h】 #ifndef MYQUEUE_H_ #define MYQUEUE_H_ struct node { struct node* next; int *connectfd; }; typedef struct node node_t; void enqueue(int* connectfd); int* dequeue(); #endif先进先出是关键),链表的每个节点为一个指向下一个节点的指针和当前待处理的客户端的connectfd(即任务)。提供入队(将accept得到的connectfd放进队尾)和出队(线程执行的入口函数会轮询队头,如果有connectfd就取出来执行相关的处理函数)两个函数。#include "myqueue.h" #include <stdlib.h> node_t* head=NULL; node_t* tail=NULL; //入队(队尾):从队尾加入一个节点 void enqueue(int *connectfd){ node_t *newnode=malloc(sizeof(node_t)); newnode->connectfd=connectfd; newnode->next=NULL; if(tail==NULL) head=newnode; else tail->next=newnode; tail=newnode; } //出队(队首):移除队首节点 int* dequeue(){ if(head==NULL) return NULL; else{ int* result=head->connectfd; node_t *temp=head; head=head->next; if(head==NULL) tail=NULL; free(temp); return result; } }- enqueue():这个函数由server.c在accept()后执行,即将得到的connectfd(唯一标识新连接成功的客户端)放入队尾,所以该函数需要创建一个节点,然后将connectfd放进去。然后用当前队尾的tail指针来将这个新节点连到链表上,并更新tail指向新的队尾。
- dequeue():弹出队首节点,并返回该节点中存储的待处理的connectfd,并更新队首。如果队首为空,即没有待处理的连接,则返回NULL。这里需要注意的就是释放malloc()创建的内存,防止内存泄露,因为当前节点的使命已经结束了,将不会再有指针指向它,所以要释放掉。
- webserver.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdbool.h> #include <limits.h> #include <pthread.h> #include "myqueue.h" //#define server_ip "192.168.2.128" #define server_port 8989 #define buf_size 4096 #define SOCKETERROR (-1) #define server_backlog 5 #define THREAD_POOL_SIZE 20 pthread_t thread_pool[THREAD_POOL_SIZE];//线程池,里面存放每个线程的线程id pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; typedef struct sockaddr_in SA_IN; typedef struct sockaddr SA; //void handle_connection(int client_socket);//单线程用这个 void* handle_connection(void* p_connect_fd); int check(int exp,const char *msg);//错误处理函数,因为很多函数错误时都是返回-1,所以可以合并这些处理操作 void* thread_function(void* arg); int main(int argc,char **argv){ int listen_fd, connect_fd, addr_size; SA_IN listen_addr,connect_addr; //创建线程池:first off create a bunch of threads to handle future connections for(int i=0;i<THREAD_POOL_SIZE;i++){ pthread_create(&thread_pool[i],NULL,thread_function,NULL); } check((listen_fd=socket(AF_INET,SOCK_STREAM,0)),"Failed to create socket\n");//检测出入的addr能否监听(未被占用) //initialize the address struct listen_addr.sin_family=AF_INET; listen_addr.sin_addr.s_addr=INADDR_ANY;//0.0.0.0 listen_addr.sin_port=htons(server_port); //bind socket to address check(bind(listen_fd,(SA*)&listen_addr,sizeof(listen_addr)),"Bind Failed!\n"); check(listen(listen_fd,server_backlog),"Listen Failed\n"); //接受连接,并把建立好的连接交给handle_connection()去处理(读写) while(true){ printf("Waiting for connections...\n"); addr_size=sizeof(SA_IN); check(connect_fd=accept(listen_fd,(SA*)&connect_addr,(socklen_t*)&addr_size),"accept fail\n"); printf("Connected!\n"); //do whatever wo do with connections //单线程处理 //handle_connection(connect_fd); //多线程处理 //pthread_t t; //int *pconnectfd=malloc(sizeof(int)); //*pconnectfd=connect_fd; //pthread_create(&t,NULL,handle_connection,pconnectfd);//NOTE:void*可以接受Int* 会强制转换,所以在函数中使用时,需要再转回去! //线程池处理 //put the connection somewhere so that an available thread can find it //------>use a special type of linked list called a queue and a queue is just like any other linked list except we always add nodes to one and we always //remove notes from the other end so it's a first-in first-out type data structure int *pconnectfd=malloc(sizeof(int)); *pconnectfd=connect_fd; pthread_mutex_lock(&mutex); enqueue(pconnectfd);//线程池中存放连接好的socketfd pthread_mutex_unlock(&mutex); } return 0; } //客户端发一个文件名给服务端,服务端将这个文件中的内容发回给客户端(这其实和浏览器向服务器要数据是一样的,只不过没有HTTP协议) //void handle_connection(int connect_fd){ void* handle_connection(void* p_connect_fd){ int connect_fd=*((int*)p_connect_fd); free(p_connect_fd); char buffer[buf_size]; size_t byte_read; int msgsize=0; char actualpath[PATH_MAX+1]; //read the client's message:the name of the file to read while((byte_read=read(connect_fd,buffer+msgsize,sizeof(buffer)-msgsize-1))>0){ msgsize+=byte_read; if(msgsize>buf_size-1||buffer[msgsize-1]=='\n') break;//读客户端发过来的文件名,直到读到换行符 } check(byte_read,"recv error"); buffer[msgsize-1]=0; printf("REQUEST:%s\n",buffer);//打印客户端的请求,即他要查的文件名 fflush(stdout); //validity check if(realpath(buffer,actualpath)==NULL){ //检查文件名是否有效 printf("ERROR(bad path):%s\n",buffer); close(connect_fd); //return; 单线程 return NULL; } //read file and send its contents to client FILE *fp=fopen(actualpath,"r"); if(fp==NULL){ printf("ERROR(open):%s\n",buffer); close(connect_fd); //return; 单线程 return NULL; } //read file contects and send them to client //note this a fine example program,but rather insecure. //a real program would probably limit the client to certain files while((byte_read=fread(buffer,1,buf_size,fp))>0){ printf("sending %zu bytes\n",byte_read); write(connect_fd,buffer,byte_read); } close(connect_fd); fclose(fp); printf("closing connection\n"); return NULL; } int check(int exp,const char *msg){ if(exp==SOCKETERROR){ perror(msg); exit(1); } return exp; } //我们不想这些线程死亡(被销毁),所以我们将他们放入一个无线循环 void* thread_function(void* arg){ while(true){ pthread_mutex_lock(&mutex); int* pconnectfd=dequeue();//取链表队首的节点(中的socketfd) pthread_mutex_unlock(&mutex); if(pconnectfd!=NULL){ //说明有链接建立好了,等待被处理 handle_connection(pconnectfd); } } }- 该程序与之前的多线程程序区别关键在于:负责监听的主线程不再负责分配connectfd给某个线程,而是将connectfd放入一个队列。负责具体和客户端通信的子线程则是无限轮训队头(抢!事干!,如果抢到了就执行handle函数,否则就接着排队轮询)。
这种隔离的设计思想(找个中间人:任务队列)是非常好的(这个模式其实就是生产者-消费者模式),我们在实际的项目应用中也常用到这种思想,它可以有效的增强程序的鲁棒性。知道这个原则后我们来看一下主线程的流程:- 创建20个子线程,开始执行thread_function()函数。
- 监听队列,接收到新连接后将connectfd放入任务队列的队尾。
- void* thread_function(void* arg):接着我们看一下线程的入口函数,即这些事先就创建好的线程都在干什么。可以看到该函数就是一个无限循环访问队头,如果非空(有未处理的连接,就取出来然后调用handle_connection()处理具体的文件读取任务),如果没有就继续下一轮循环排队拿锁。
- 这样分析完整个服务端的代码就十分清晰了,就是以下整个模式
其中
主线程就是生产者,子线程就是消费者。主线程循环阻塞在accept()处等待新的客户端连接,有连接来时就产生一个connectfd来唯一地标识这个连接,然后把这个connectfd送入任务队列队尾。而子线程则是都在无限循环排队取队首。
- 该程序与之前的多线程程序区别关键在于:负责监听的主线程不再负责分配connectfd给某个线程,而是将connectfd放入一个队列。负责具体和客户端通信的子线程则是无限轮训队头(抢!事干!,如果抢到了就执行handle函数,否则就接着排队轮询)。
- 运行测试:
测试过程和上一节多线程一样,这里就不多讲了。可以看到多个客户端都可以通过线程池来处理。
- 接下来我们稍微修改一下服务端和客户端的代码,看看都都是哪些线程抢到了任务:
在服务端程序中将进程池size改为3,然后在thread_function()中调用(int)pthread_self()来打印当前执行任务的线程id。然后调用客户端10次,可以看到76线程抢到了4次任务,72线程抢到了2次任务,78线程抢到了4次。经过这个例子,对线程池最基本的运行原理我们应该了解了,当然这只是线程池最简单的一个小例子,这样的无限循环太消耗资源了,应该在适当的时候让其睡眠(挂起),有需要时再将其唤醒,这就需要用到一个新的知识信号量,这一块我们放在epoll中再学习。
四、总结
- 本文我们从多线程讲起,结合上一篇文章的BIO模型,实现了多线程BIO,但是由于多线程BIO在应对大量连接时占用内存太大,出现性能退化甚至程序崩溃的问题,我们又实现了一个简单的线程池BIO来解决这个问题,但是线程池BIO也有着自己的问题,不管有没有任务,它线程池中的线程都在不停歇的运转,这个问题我们将在下一篇文章中通过信号量来解决。
五、Reference
- 本文的代码例程来源于YouTube上一位老师的教学视频(非常建议新手看这版视频,循序渐进、简明扼要),地址如下www.youtube.com/c/JacobSorb…
- 一个Github上star很多的Linux C线程池实现方案,地址如下github.com/Pithikos/C-…
学习中记录,如果有误还请指出。