在网络编程------UDP协议网络程序一文中编写了根据传输层的UDP协议来进行服务器与客户端通信的代码,并介绍了相关的概念。在本文中将编写基于TCP协议的服务器与客户端通信代码。并对比TCP与UDP协议之间的差别。下面先介绍TCP协议。
TCP协议
TCP协议与UDP一样,都是基于传输层的协议。两个网络进程根据TCP协议在进行通信时,首先要相互建立连接。待双方确认连接建立成功之后,才可进行通信(UDP协议不用建立连接,直接进行通信)。这样做可以确保数据传送的可靠性,但同时因为建立连接等需要花费时间和资源,因此速度相对UDP会相对较慢。与UDP面向数据报传送方式的不同,TCP是面向字节流进行传送的,即发送方发送一定字节的数据,接收方可以以任意的长度接收。比如发送方一次发送了20字节的数据,接收方可以一次接受1个,2个字节等。而UDP协议要求发送方一次发送多少,接收方一次就必须接收多少。
因此,TCP协议具有以下特点:
(1)是传输层的协议
(2)面向连接,速度相对TCP会较慢,成本会相对较高
(3)保证可靠性
(4)面向字节流
下面基于TCP协议的简单网络程序。
在编写代码之前还要再认识一些接口函数。(部分接口函数已在网络编程------UDP协议网络程序中给出)
接口函数
1. 地址转换函数
基于TCP协议的网络编程,也要通过网络来进行通信,因此与UDP相同也要对IP地址进行相应的转换。
在UDP中介绍了IPv4类型的IP地址格式转换的两个函数,下面在介绍几个相关函数:
“点分十进制”转换为整型地址
int inet_aton(const char *cp, struct in_addr *inp);//头文件:<sys/socket.h> <netinet/in.h> <arpa/inet.h>
该函数与inet_addr函数一样,都只适用于IPv4类型的IP地址。
参数:
cp:需转换的“点分十进制”字符串类型的IP地址
inp:转换后的整型地址,该整型地址被封装在结构体struct in_addr中。所以inp是一输出型参数
返回值:成功返回0,失败返回-1。
#include <arpa/inet.h> int inet_pton(int af, const char *src, void *dst);
该函数具有通用性,它适用于任意套接字类型的IP地址,具体是转换哪种地址,由参数给出。
参数:
af:套接字类型的地址标识,即struct sockaddr结构体的第一个成员。如对于IPv4类型的IP地址,该参数为AF_INET。
src:需转换的“点分十进制”字符串类型的IP地址
dst:该参数指向转换后的整型地址。因此该参数也是一输出参数。
返回值:成功返回0,失败返回-1。
整型地址转换为“点分十进制”字符串
在UDP中介绍过的inet_ntoa函数为:
char *inet_ntoa(struct in_addr in);
在转换时该函数内部为我们在静态存储区申请了一片内存存放转换后的字符串,然后将这片内存的地址以返回值的形式带回。所以,不需要我们手动释放。
但是,当多次调用该函数时,后面调用的结果会覆盖前面的结果。也就是说每次调用转换后的结果都放在同一内存中。因此,在多线程环境中调用该函数时,这片区域就相当于临界资源,多个线程都可进行访问。因此可能会出现异常。所以该函数不是线程安全函数。
所以,可以调用以下函数,来由我们自己提供存放字符串的内存,同时该函数适用于任意类型的IP地址转换:
#include <arpa/inet.h> const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);
参数:
af:16位的IP地址类型标识,如AF_INET
src:指向要转换的变量
dst:存放转换后的“点分十进制”字符串,因此它是一个输出型参数
size:dst的字节长度
返回值:成功返回0,失败返回-1
2. listen函数
#include <sys/types.h> #include <sys/socket.h> int listen(int sockfd, int backlog);
函数功能:该函数用于客户端,使sockfd处于监听状态,处于监听状态的网卡文件才能接受客户端发来的连接请求。
参数:
sockfd:socket函数返回的文件描述符
backlog:等待队列的中等待连接的个数。
返回值:成功返回0,失败返回-1.
注意:
当系统中的资源不足以支持与客户端进行连接并提供服务时,此时就要是请求的连接处于等待队列中。待系统中有多余的资源时,在进行连接。
为保证服务器一直处于忙碌状态,就必须维护一个等待队列。因为如果不维护等待队列,当服务器资源不足时时,客户端发来的连接请求就会被忽略,当服务器空闲下来时,可能没有连接请求发来,此时,服务器就可能处于空闲状态,而使资源得不到利用。所以,必须维护一个等待队列。
同时,这个等待队列不能太长。因为等待队列的维护是需要消耗资源的。应将更多的资源用于服务上,所以一般将等待队列的长度设置为5,当等待队列中的请求连接数超过5时,就直接忽略多余的连接。
3. connect函数
#include <sys/types.h> #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
函数功能:客户端通过调用该函数向服务器端发起连接请求
参数:
sockfd:客户端程序中由socket函数返回的文件描述符
addr:客户端要连接的服务器端的存放套接字相关信息的结构体
addrlen:上述结构体的长度。
返回值:成功返回0,失败返回-1。该函数调用成功,即表示三次握手建立成功。
4. accept函数
#include <sys/types.h> #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
函数功能:当connect函数调用成功客户端与服务器端成功连接后,即三次握手完成后。服务器端调用该函数接受连接。
参数:
sockfd:服务器程序中由socket函数返回的文件描述符
addr:该变量指向接收的客户端的有关套接字的结构体,如果设置为NULL,就表示服务器端不关心客户端的地址所以是一输出型参数。
addrlen:上述结构体的长度。它是一个输入输出型参数。传入的是调用者提供的缓冲区addr的长度,以避免缓冲区溢出。输出的是实际客户端结构体变量addr的长度,此时,有可能没有占满调用者提供的缓冲区的大小。
返回值:当调用该函数时,还没有客户端发来的连接请求,就阻塞等待。当连接建立成功,服务器端成功接收后,返回客户端的网卡文件描述符,失败返回-1。
下面编写一个基于TCP协议的简单的网络程序。实现客户端与服务器端的相互通信。
基于单进程的服务器
(1)首先,在服务器程序中要调用socket打开一个网卡文件用于网络通信
(2)调用bind将服务器程序与特定的IP地址和端口号进行绑定,以便客户端能找到该服务器与之进行连接通信
(3)因为该服务器是基于TCP协议的,所以要使上述的网卡文件处于监听状态才能接受客户端发来的连接请求。
(4)当客户端调用connect与服务器端建立连接成功后,服务器需要调用accept来接收连接
(5)然后双方开始进行通信
(6)因为可能有多个客户端需要与服务器建立连接请求,因此服务器需要不断的调用accept来接受连接请求,所以应使(4)~(5)处于一个无限循环中。
因为服务器端需要绑定一个众所周知的IP地址和端口号才能使客户端找到它,因此将IP地址和端口号以命令行参数的形式传入。
服务器程序代码如下:
#include<stdio.h> #include<sys/types.h> #include<sys/socket.h> #include<stdlib.h> #include<netinet/in.h> #include<arpa/inet.h> #include<unistd.h> #include<string.h> //命令行输入的格式为:./a.out 192.168.3.95 8080 int main(int argc,char* argv[]) { //如果命令行传入的参数个数不为3,弹出用法说明 if(argc != 3) { printf("Usage:%s [ip][port]\n",argv[0]); return 1; } //打开网卡文件,将其绑定到指定的端口号和IP地址,并使其处于监听状态 int listen_sock = server_sock(argv[1],atoi(argv[2]));//得到监听套接字 printf("bind and listen success,wait accept...\n"); //绑定并监听成功后,双方开始通信 struct sockaddr_in client;//定义存放客户端套接字信息的结构体 while(1) { socklen_t len = sizeof(client);//调用者指定存放结构体的缓冲区的大小 //服务器端调用该函数阻塞等待客户端发来连接请求 //如果连接建立成功之后,该函数接受客户端的链接请求 int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len); if(client_sock < 0)//接受失败 { printf("accept error\n"); continue; } char ip_buf[24]; ip_buf[0] = 0; //转换整型IP地址为字符串格式 inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf)); //将从网络中接受的端口号转换为主机序列 int port = ntohs(client.sin_port); printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port); //此时,双方开始互发消息进行通信 server_work(client_sock,ip_buf,port); } return 0; }
//得到监听套接字函数 int server_sock(char* ip,int port) { //打开网卡文件,得到文件描述符 int sock = socket(AF_INET,SOCK_STREAM,0); if(sock < 0) { printf("socker error\n"); exit(1); } struct sockaddr_in server; bzero(&server,sizeof(server));//使结构体server清零 server.sin_family = AF_INET; server.sin_addr.s_addr = inet_addr(ip); server.sin_port = htons(port); socklen_t len = sizeof(server); // //一个服务器可能有多个网卡,一个网卡也可能绑定多个IP地址 // //INADDR_ANY可以设置在所有IP地址上进行监听, // //直到客户端发来与指定的IP地址进行连接时,才确定使用哪个IP地址 // server.sin_addr.s_addr = htonl(INADDR_ANY); //服务器需绑定固定的IP地址和端口号才能使客户端正确找到 if(bind(sock,(struct sockaddr*)&server,len) < 0) { printf("bind error\n"); exit(2); } //使sock处于监听状态,并且最多允许5个客户端处于连接等待状态,多于5的链接请求直接忽略 if(listen(sock,5) < 0) { printf("listen error\n"); exit(3); } return sock;//得到监听套接字 }
在上述代码中,以下语句:
server.sin_addr.s_addr = inet_addr(ip);
可以替换为:
server.sin_addr.s_addr = htonl(INADDR_ANY);
INADDR_ANY是一个宏,表示本地的任意IP地址。因为服务器可能有多个网卡,一个网卡可能对应多个IP地址,所以可以绑定多个IP地址,在所有的IP地址上进行监听,当客户端指定与某个IP地址进行连接时才确定使用哪个IP地址。
//客户端与服务器端建立连接之后的通信函数 void server_work(int client_sock,char* ip,int port) { char buf[128]; while(1) { buf[0] = 0;//清空字符串 //服务器从客户端接受信息,如果客户端没有发来信息就阻塞等待 ssize_t s = read(client_sock,buf,sizeof(buf) - 1); if(s > 0) { buf[s] = 0; printf("ip:%s,port:%d say# %s\n",ip,port,buf); } //如果读到的为0,说明此时客户端关闭了文件描述符,与之断开了连接 //所以此时服务器应直接退出通信函数。 else if(s == 0) { printf("ip:%s,port:%d quit\n",ip,port); break; } else { printf("read error\n"); break; } //服务器端向客户端发送信息 printf("please enter#"); fflush(stdout); buf[0] = 0; int ss = read(0,buf,sizeof(buf) - 1); if(ss > 0) { buf[ss - 1] = 0; } //将从键盘读到的信息写入客户端的网卡文件向其发送信息 write(client_sock,buf,strlen(buf)); printf("waiting ...\n"); } }
TCP客户端
(1)首先,在客户端程序中打开网卡文件,得到文件描述符
(2)客户端不需要绑定固定的端口号,它的端口号是由内核自动进行分配。所以直接调用connect向服务器端发送连接请求
(3)当连接成功,服务器端调用accept接收客户端的连接请求后,双方便开始进行通信,规定客户端发送“quit”时,表明此时客户端断开连接,此时直接关闭网卡文件即可。
客户端要根据服务器端绑定的固定的IP地址和端口号找到服务器,并向其发送请求连接,所以应将服务器端固定的IP地址和端口号以命令行参数的形式传入:./a.out 192.168.3.95 8080
客户端代码如下:
#include<stdio.h> #include<sys/types.h> #include<sys/socket.h> #include<stdlib.h> #include<netinet/in.h> #include<arpa/inet.h> #include<unistd.h> #include<string.h> int main(int argc,char* argv[]) { //用法说明 if(argc != 3) { printf("Usage:%s [ip][port]\n",argv[0]); return 1; } //打开网卡文件 int sock = socket(AF_INET,SOCK_STREAM,0); if(sock < 0) { printf("socket error\n"); return 2; } struct sockaddr_in server; server.sin_family = AF_INET; server.sin_addr.s_addr = inet_addr(argv[1]); server.sin_port = htons(atoi(argv[2])); socklen_t len = sizeof(server); //向服务器端发送连接请求 if(connect(sock,(struct sockaddr*)&server,len) < 0) { printf("connect failed\n"); return 3; } printf("connect success\n"); //连接成功后双方开始相互通信 char buf[128]; while(1) { buf[0] = 0; printf("please enter#"); fflush(stdout); ssize_t s = read(0,buf,sizeof(buf) - 1); if(s > 0) { buf[s - 1] = 0;//去掉\n,如果不去掉,在与quit比较时,quit需加上\n //当客户端发送quit时表明要与服务器端断开链接 if(strcmp(buf,"quit") == 0) { printf("client quit\n"); break; } //向服务器端发送消息 write(sock,buf,strlen(buf)); printf("waiting ...\n"); } //从服务器端接受消息 buf[0] = 0; ssize_t ss = read(sock,buf,sizeof(buf) - 1); if(ss > 0) { buf[ss] = 0; printf("server say:%s\n",buf); } } //当客户端断开连接后,关闭客户端的文件描述符 close(sock); return 0; }
运行结果:
服务器端先运行起来,等待接收客户端的连接请求:
此时,再运行客户端代码向服务器端请求连接:
服务器端显示如下:
此时,二者便建立连接成功,接下来互发消息进行通信,以下为客户端界面:
以下为服务器端界面:
注意:上述是用本地环回IP地址进行测试。本地环回可以测试网络程序,在有无网络的情况下都可正常测试,因为它会经过协议栈,但不经过网络。在实际应用时,应使用服务器端的IP地址进行测试。
在上述程序中,一个客户端在与服务器端建立连接进行通信时,其他的客户端再向服务器端发送连接请求时,会连接不上。因为,在上述服务器端的程序中,当进程接收到第一个客户端发来的请求时,会进入与该客户端进行通信的死循环中,而无法再调用accept接受来自其它客户端的请求。当第一个客户端与之断开连接之后,服务器进程才会跳出死循环接收来自其它客户端的连接请求,此时也是,一次只能与一个客户端进行连接通信。
在实际应用中,服务器应同时接收处理多个客户端的请求,所以需要有多个执行流。因此,上述的服务器端代码还需要进行修改。
基于多进程的TCP服务器
在上述单进程的服务器中,因为进程要与客户端进行通信,所以无法再accept接受新的客户端请求,此时,可以利用创建子进程的方式来提供多个执行流,当一个客户端发来连接请求时,主进程创建一个子进程与客户端进行通信,而父进程的任务是不断接受新的客户端的请求,创建子进程。当子进程退出时要父进程要通过等待来回收子进程的资源。所以大致代码如下:
while(1) { accept();//父进程接收连接 pid_t pid = fork(); if(pid == 0)//child { server_work(); } waitpid();//父进程等待 }
在上述代码中,当一个客户端发来请求时,父进程创建子进程与之通信。如果父进程阻塞式等待,则当客户端连接断开时,父进程一直在等待也不能接受来自其它客户端的连接请求,此时,与上述的单进程服务器缺陷相同。如果父进程非阻塞式等待,父进程也要一直轮询式的查看子进程有没有退出,这样会使资源得到浪费。当然也可以采用忽略子进程退出时的SIGCHLD信号来使子进程自己回收资源,当时这样做也比较麻烦。因此,上述的处理方法也不可取。
所以,采用如下的处理方法:
while(1) { accept();//父进程接收连接 pid_t pid = fork(); if(pid == 0)//child { pid_t pid1 = fork(); if(pid1 == 0)//孙子进程 { server_work(); } exit(0); } waitpid();//父进程等待 }
首先父进程创建子进程,为避免父进程阻塞等待子进程,使子进程创建好之后直接退出,此时父进程就可以立即回收子进程的资源了。而与客户端的通信工作则交给孙子进程来完成,所以在子进程创建好之后,子进程在创建孙子进程,由孙子进程与客户端进行通信,子进程直接退出。此时孙子进程会变成孤儿进程被1号进程领养,当孙子进程退出时由1号进程回收资源。而父进程在孙子进程创建完成之后会立即回收子进程避免了等待的工作,同时也可以不断地接收来自客户端的连接请求。
因此将服务器端的部分代码修改如下:
在main函数中,修改如下:
while(1) { socklen_t len = sizeof(client); int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len); if(client_sock < 0) { printf("accept error\n"); continue; } char ip_buf[24]; ip_buf[0] = 0; inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf)); int port = ntohs(client.sin_port); printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port); process_work(client_sock,listen_sock,ip_buf,port);//父进程创建子进程 close(client_sock); }
父进程创建子进程,子进程再创建孙子进程:
void process_work(int client_sock,int listen_sock,char* ip_buf,int port) { pid_t pid = fork(); if(pid < 0) { printf("fork error\n"); return; } else if(pid == 0)//child { pid_t pid1 = fork(); if(pid1 < 0) { printf("fork error\n"); return; } else if(pid1 == 0)//grandchild { close(listen_sock);//关闭不用的文件描述符 server_work(client_sock,ip_buf,port); } close(client_sock);//关闭不用的文件描述符 exit(0); } else//father { close(client_sock);//关闭不用的文件描述符 waitpid(pid,NULL,0); } }
此时,在处理多个客户端连接请求时,就可以正常运行了。
同时需要注意的是:在上述程序中,子进程和孙子进程的文件描述表都是从父进程那里继承过来的,所以三者的文件描述符表相同。
而父进程的工作是接收来自客户端的连接请求,并不与客户端进行通信,所以需要关闭来自客户端的文件描述符(文件描述符有上限)。同理,子进程也要关闭该文件描述符。而对于孙子进程来说,它的工作是与客户端进行通信,而不接受连接请求,所以需要关闭服务器端打开的文件描述符。
基于多线程的TCP服务器
服务器也可以通过创建多线程的方法来提供多个执行流,主线程接收客户端发来的请求并创建新线程,新线程与客户端进行通信。在上述多进程的程序中主要考虑的问题是父进程在阻塞式等待时不能接收连接请求。多线程环境中主线程理应对新线程进行回收。但是pthread库提供一个个系统调用可以分离新线程,当新线程退出时自己回收资源,不必主线程来回收,所以主线程在创建完新线程之后,直接对其进行分离,就可以继续不断接受连接请求了。
对单进程的服务器代码修改如下:
main函数中的代码修改:
while(1) { socklen_t len = sizeof(client); int client_sock = accept(listen_sock,(struct sockaddr*)&client,&len); if(client_sock < 0) { printf("accept error\n"); continue; } char ip_buf[24]; ip_buf[0] = 0; inet_ntop(AF_INET,&client.sin_addr,ip_buf,sizeof(ip_buf)); int port = ntohs(client.sin_port); printf("connect success, ip:[%s],port:[%d]\n",ip_buf,port); pthread_t tid; Sock* Arg = (Sock*)malloc(sizeof(Arg)); Arg->sock = client_sock; Arg->ip = ip_buf; Arg->port = port; int ret = pthread_create(&tid,NULL,&pthread_run,Arg); if(ret < 0) { printf("pthread_create error\n"); continue; } pthread_detach(tid); }
新线程创建完成之后去执行pthread_run函数,参数为Arg,因此在与客户端进行通信时,需要用到文件描述符,IP地址和端口号等,所以将它们封装在一个结构体中,作为该函数的参数。然后在该函数中与客户端进行通信:
typedef struct Sock { int sock; char* ip; int port; }Sock;
void* pthread_run(void* Arg1) { Sock* Arg = (Sock*)Arg1; server_work(Arg->sock,Arg->ip,Arg->port);//该函数与单进程中的函数相同 }
此时,在测试时也可达到与多进程相同的作用:可以处理多个客户端的连接请求并与之进行通信。
简述多进程和多线程的TCP服务器的优缺点
缺点:
(1)在多进程和多线程环境中,都是当客户端发来连接请求时,服务器端才开始创建子进程和新线程。此时会使客户端进行等待,浪费客户端的时间,使性能受损。因为进程的创建比线程的创建花费的时间多,所以多进程的服务器相对多线程在时间这点上来说会受损严重。
(2)进程和线程的创建都需要消耗资源,也会使性能受损。但进程比线程需要更多的资源,所以多线程在资源消耗上会受损较轻。
(3)多进程多线程环境中CPU都要进行调度,此时也会使客户端进行等待,会影响性能。但进程的切换成本会相对线程高,所以影响较大。
(4)因为多线程是在进程的同一地址空间内运行,当一个线程出现问题时,整个进程即该进程中的其他线程也会受到影响。所以,多线程服务器的稳定性较差。
优点:
(1)多线程和多进程都可以处理多个客户端的请求
(2)上述多线程和多进程的程序编写都比较简单,周期短
(3)多进程中一个进程出现问题,其他进程不会受影响。所以多进程服务器稳定性较好。
UDP协议与TCP协议对比
TCP是面向连接,而UDP是无连接的。
(1)TCP花费的时间会相对UDP长
(2)TCP消耗的资源也会相对UDP多
(3)UDP的速度会相对TCP较快
(4)同时TCP比UDP更可靠。
TCP 是面向字节流的,而UDP是面向数据报的,因此
(5)TCP在消息的接收上会更加灵活。