使用UDP协议通信时服务器端与客户端无需提前建立连接,只需要知道对方的套接字地址信息就可以发送数据。服务器端只需创建一个套接字用于接收不同客户发来的请求,经过处理即可通信。
由于没有事先建立连接,因此使用UDP协议通信时无法保证数据是否成功接收,因此,若需要保证数据可靠性,则不要使用UDP通信。
一、UDP编程——服务器端
使用UDP通信的服务器端的编程流程如下:
如果需要使用UDP协议创建一个服务器,则需要以下步骤:
-创建套接字
-绑定套接字
-接收/发送数据
-断开连接
相比TCP编程,由于没有监听listen()与接收accept(),因此流程较短,编程相比TCP编程更加简单,但是数据可靠性也不复存在
//与TCP编程相同的函数不再赘述
//注意socket()的参数需要选择SOCK_DGRAM
sendto()函数的用法:
函数sendto()
所需头文件:#include<sys/types.h>
#include<sys/socket.h>
函数原型:int sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen)
函数参数:
sockfd 需要发送数据的套接字文件描述符
buf 发送数据的缓冲区首地址
len 发送数据的缓冲区长度
flags 通常设定为0
dest_addr 接收方套接字的IP地址与端口号的结构体
addrlen dest_addr结构体的大小
函数返回值:
成功:实际发送的字节数
失败:-1
//send(sockfd,buf,len,0)等价于sendto(sockfd,buf,len,0,NULL,0);
recvfrom()函数的用法:
函数recvfrom()
所需头文件:#include<sys/types.h>
#include<sys/socket.h>
函数原型:int recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen)
函数参数:
sockfd 需要接收数据的套接字文件描述符
buf 接收数据的缓冲区首地址
len 接收数据的缓冲区长度
flags 通常设定为0
src_addr 发送方套接字的IP地址与端口号的结构体
addrlen src_addr结构体的大小
函数返回值:
成功:实际接收的字节数
失败:-1
二、UDP编程——客户端
使用UDP通信的客户端的编程流程如下:
使用UDP协议创建一个访问UDP服务器的客户机,需要以下步骤:
-创建套接字
-绑定套接字(可选)
-接收/发送数据
-断开连接
//上文出现过的函数不再讲解
示例:使用UDP进行通信,代码分成服务器端和客户端两部分
//服务器端
//文件server.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#define BUFFER 128
int main(int argc, const char *argv[])
{
int sockfd;
struct sockaddr_in servaddr,cliaddr;
socklen_t peerlen;
char buf[BUFFER];
if(argc<3)
{
printf("too few arguments\n");
printf("Usage: %s <ip> <port>\n",argv[0]);
exit(0);
}
if((sockfd = socket(AF_INET,SOCK_DGRAM,0))<0)
{
perror("socket");
exit(0);
}
printf("sockfd = %d\n",sockfd);
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);
if(bind(sockfd,(struct sockaddr *)&servaddr,sizeof(servaddr))<0)
{
perror("bind");
exit(0);
}
printf("bind success\n");
peerlen = sizeof(cliaddr);
while(1)
{
if(recvfrom(sockfd,buf,sizeof(buf),0,(struct sockaddr *)&cliaddr,&peerlen)<0)
{
perror("recvfrom");
exit(0);
}
printf("Received a message: %s\n",buf);
strcpy(buf,"Welcome to server");
sendto(sockfd,buf,sizeof(buf),0,(struct sockaddr *)&cliaddr,peerlen);
}
close(sockfd);
return 0;
}
//客户端
//文件client.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#define BUFFER 128
int main(int argc, const char *argv[])
{
int sockfd;
char buf[BUFFER] = "Hello Server";
struct sockaddr_in servaddr;
if(argc<3)
{
printf("too few arguments\n");
printf("Usage: %s <ip> <port>\n",argv[0]);
exit(0);
}
if((sockfd = socket(AF_INET,SOCK_DGRAM,0))<0)
{
perror("socket");
exit(0);
}
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);
sendto(sockfd,buf,sizeof(buf),0,(struct sockaddr*)&servaddr,sizeof(servaddr));
if(recvfrom(sockfd,buf,sizeof(buf),0,NULL,NULL)<0)
{
perror("recvfrom");
exit(0);
}
printf("receive from server:%s\n",buf);
close(sockfd);
return 0;
}
在使用UDP协议时,可以尝试不运行服务器端直接运行客户端,会发现客户端仍然可以发送信息(只不过不会被接收),而TCP协议必须首先与服务器连接后才可以传输信息。
练习:使用UDP协议连接服务器端与客户端,客户端可以不断向服务器端传输信息,直至输入特定信息(例如"byebye")服务器才会与客户端断开连接
//服务器端
//文件server.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#define BUFFER 128
int main(int argc, const char *argv[])
{
int sockfd;
struct sockaddr_in servaddr,cliaddr;
socklen_t peerlen;
char buf[BUFFER];
if(argc<3)
{
printf("too few arguments\n");
printf("Usage: %s <ip> <port>\n",argv[0]);
exit(0);
}
if((sockfd = socket(AF_INET,SOCK_DGRAM,0))<0)
{
perror("socket");
exit(0);
}
printf("sockfd = %d\n",sockfd);
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);
if(bind(sockfd,(struct sockaddr *)&servaddr,sizeof(servaddr))<0)
{
perror("bind");
exit(0);
}
printf("bind success\n");
peerlen = sizeof(cliaddr);
while(1)
{
if(recvfrom(sockfd,buf,sizeof(buf),0,(struct sockaddr *)&cliaddr,&peerlen)<0)
{
perror("recvfrom");
exit(0);
}
printf("Received a message: %s",buf);
if(strncmp(buf,"byebye",6)==0)
{
printf("Disconnect\n");
break;
}
}
close(sockfd);
return 0;
}
//客户端
//文件client.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#define BUFFER 128
int main(int argc, const char *argv[])
{
int sockfd;
char buf[BUFFER];
struct sockaddr_in servaddr;
if(argc<3)
{
printf("too few arguments\n");
printf("Usage: %s <ip> <port>\n",argv[0]);
exit(0);
}
if((sockfd = socket(AF_INET,SOCK_DGRAM,0))<0)
{
perror("socket");
exit(0);
}
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);
while(1)
{
printf("Please input string(input \"byebye\" to stop):");
fgets(buf,BUFFER,stdin);
sendto(sockfd,buf,sizeof(buf),0,(struct sockaddr*)&servaddr,sizeof(servaddr));
if(strncmp(buf,"byebye",6)==0)
{
printf("Disconnect\n");
break;
}
}
close(sockfd);
return 0;
}
运行这个练习代码时,可以尝试不打开服务器端,直接运行数据端发送数据。可以发现客户端是可以直接发送数据的,但是由于服务器端未打开,因此无法接收。而TCP协议的客户端则无法发送数据(Connection refused访问被拒绝)。
造成这种现象的原因是因为TCP是可靠连接,而UDP是不可靠连接。
一、IO模型
1、IO模型与分类分类
IO,即input&output,指的是计算机与用户之间进行的数据交互方式。在Linux系统中,不同情况下适用的IO场景也不尽相同。
Linux系统将IO分成5种IO模型,分别是:
-阻塞型IO(blocking IO)
最常见的IO模型,大多数的程序的默认IO模型都是阻塞型IO。例如之前我们熟悉的scanf()、read()、write()、recv()、send()、accept()、connect()等函数都使用该IO模型。
-非阻塞型IO(Non-blocking IO)
并不常见的IO类型。采用非阻塞型IO的程序会在IO失败时立即返回错误值。
-IO多路复用(IO Multiplexing)
当程序中同时处理多路IO时采用。
-信号驱动型IO(signal driven IO)
非常罕见,这里不讨论。
-异步IO(asynchronous IO)
从Linux内核2.6版本后才引入的IO模型,不太常见。简单说是read()端与write()端可以不同时运行,当读取数据端调用read()时可立即进行其余操作,等待内核返回“数据读取完毕”信号后再将数据读取。
//本文中重点讨论前三种IO模型,即阻塞型IO、非阻塞型IO和IO多路复用。
2、阻塞(blocking)
在IO模型中,我们提到了“阻塞”这个概念。什么是阻塞呢?
阻塞(blocking)指的是在函数得到调用结果返回之前,由于种种原因进程并未得到操作系统的立即响应,进程暂时被挂起,等待相应事件出现后才会被唤醒。
例如在我们熟悉的scanf()函数中,当我们在代码中使用scanf()读取数据时,若暂时未输入数据,此时进程会一直被挂起直至用户输入相应数据为止。在数据被送入stdin之前,整个进程处在阻塞状态,直至用户输入完成才会继续运行。
缺点:浪费大量系统资源
3、阻塞型IO
//阻塞型IO模型
阻塞型IO是Linux系统内最普遍的IO模式,大多数程序在默认情况下都使用阻塞型IO进行输入输出。之前学习过的大多数读/写函数都是阻塞型IO,例如:
-读操作:read()、recv()、recvfrom()等
-写操作:write()、send()等
-其他操作:accept()、connect()等
具体还可以细分成 读阻塞 和 写阻塞 两种:
1.读阻塞(以recvfrom()为例)
当用户进程调用了一个recvfrom()函数时,此时进程从用户态切换至内核态,kernel开始IO第一阶段:准备数据。在有足够的数据到来之前,内核不会进行进一步动作直至数据全部接收。当所有数据接收完毕,kernel开始IO第二阶段:数据拷贝。kernel将收到的数据全部从内核空间拷贝到用户空间中,然后kernel返回结果。在阻塞型IO的两个阶段(等待数据阶段和拷贝数据阶段),整个用户进程都被阻塞,即从“调用recvfrom()函数”开始,直至“收到内核返回结果”,整段时间内用户进程都处在被阻塞的状态中。
2.写阻塞
写阻塞的发生状况比读阻塞少得多。主要发生在当写入的缓冲区空间不足时,后续数据不会立即写入缓冲区,等待缓冲区内重新有写入空间。在等待缓冲区有空间这段时间内进程会出现写阻塞的情况。
4、非阻塞型IO
//非阻塞型IO模型
当我们使用非阻塞型IO进行数据读写时,内核会对本次IO是否能够立即获得数据进行判断:
-若可以立即得到结果,则直接操作,并返回成功
-若无法立即得到结果,则会立即返回错误而不会阻塞进程等待
使用非阻塞型IO时,本次IO是否成功会立即得到结果而不会像阻塞型IO一样一直挂起等待。当然,由于无法得知何时能够得到数据,所以需要一个循环来不停测试数据是否已经可读。该过程称为"polling"(原意为“民意选举”,这里指“调查”)。
使用非阻塞型IO时,进程需要不断poling内核检查IO操作是否已完成,这是一个十分浪费CPU的操作,因此非阻塞型IO基本不采用。
二、多路复用
1、多路复用(IO Multiplexing)概念
·设想这个场景:一个机场需要管理许多航班的运行,每个航班有进港、出港、泊停、排位、接客等状态。
最简单的方法:招聘一大堆空管员,每个空管员专盯一架飞机,全权负责该飞机的每个状态直至交接给下一个机场。若这样做,会出现许多问题:
-空管中心里聚集了一大批空管员,立即人满为患,若后续还有航班需要管理,新的空管员很难再挤进空管中心(运行空间与处理能力有上限)
-空管员之间需要协同工作,一大批空管员很快将空管中心变成菜市场(通信成本大幅度增加)
-空管员需要一些公共资源(例如显示屏、计算机等),当空管员过量,大量的时间将被浪费在抢占这些共有资源上(争夺资源浪费时间)
那么如何解决呢?现实中我们知道,少数的空管员即可完成一整个机场的航班管理。空管员利用一种叫"flight progress strip"的设备,每当有航班信息更新,就在该设备内记录该航班的状态。
与示例类似,当应用程序需要同时处理多路输入输出流时,该程序需要同时考虑到多方面因素:
-若使用阻塞型IO,则当前一个IO操作未完成时,后续的IO操作都无法运行
-若使用非阻塞型IO,则需要不断进行polling轮询IO操作是否完成,十分浪费CPU运行时间
-若设置多个进程,每个进程独立处理一个输入输出流,则需要进行进程的同步与互斥操作,程序将会更加复杂
比较合理的方案是使用IO多路复用达到目的。
IO多路复用指的是:当有若干个传输数据的请求时,为了有效利用通信传输线路,将多个传输数据放在同一条通信传输线路传输的方式。
具体到Linux系统中,当我们有多个文件描述符需要传输数据时,Linux内核会监控每个文件描述符的状态。当有数据到达时,Linux内核激活对应的文件描述符并读取数据。通过这个操作,我们可以实现在一个进程中同时处理多个IO请求的操作。
//有些资料称这种方式为“时分复用”,本质相同
2、多路复用函数
在Linux系统中,常用的实现IO复用的函数有三个:select()、poll()和epoll()函数族
1.select()函数
select()函数是最早(1983年在BSD内)实现IO多路复用的函数。select()的用法如下:
函数select()
所需头文件:#include<sys/types.h>
#include<sys/time.h>
#include<unistd.h>
函数原型:int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
函数参数:
nfds 所有监控的文件描述符中的最大值加1(注意必须加1否则可能出错)
readfds 需要监控的所有读的文件描述符
writefds 需要监控的所有写的文件描述符
exceptfds 需要监控的所有额外操作的文件描述符(例如错误检查等)
//readfds、writefds、exceptfds三个参数需要使用特定的函数设定,见下
//三个参数可以省略,若省略则设置为NULL
timeout 超时时间。设定select()的等待时长。其中必须设定struct timeval类型结构体变量并使用地址传递,该结构体如下:
//该结构体在头文件<sys/time.h>中
struct timeval
{
long tv_sec; //秒
long tv_usec; //毫秒
};
若该参数设定为NULL,则表示一直阻塞直至有文件描述符准备就绪。若不为NULL,则会在指定时长内等待事件发生直至超时返回。
函数返回值:
成功:返回处于就绪态并且包含在fd_set结构中的描述符总数
失败:
0 超时
-1 错误
设定readfds、writefds、exceptfds三个参数需要使用以下几个函数设定:
void FD_SET(int fd, fd_set *fdset) //将fd加入fdset
void FD_CLR(int fd, fd_set *fdset) //将fd从fdset中剔除
void FD_ZERO(fdset *fdset) //清除fdset中所有文件描述符
int FD_ISSET(int fd, fd_set *fdset) //判断fd是否在fdset中。在调用select()之后使用
调用select()之后,进程会一直等待直至出现以下某种情况:
-有文件可读
-有文件可写
-超时
示例1:使用select()监控stdin,若stdin在5s内无数据,则打印提示信息;若有数据,则输出数据
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#define MAXSIZE 128
int main()
{
fd_set rfds;//监控的文件描述符集合
struct timeval tv;//设定超时时间
int retval;
char readbuffer[MAXSIZE];
FD_ZERO(&rfds);//使用之前先清空
FD_SET(0, &rfds);//将stdin(文件描述符为0)加入rfds
tv.tv_sec = 5;//设定时间5秒
tv.tv_usec = 0;
retval = select(1, &rfds, NULL, NULL, &tv);//注意第一个参数为0+1=1
if (retval == -1)
perror("select()");
else if (retval)//监控到某个文件描述符就绪
{
printf("Data is available now.\n");
if(FD_ISSET(0, &rfds)!=0)//需要使用FD_ISSET()查看是哪个文件描述符就绪
{
fgets(readbuffer,MAXSIZE,stdin);
printf("string:%s",readbuffer);
}
}
else//超时
printf("No data within five seconds.\n");
return 0;
}
示例2:在TCP服务器端使用select(),监控stdin与客户端连接。若stdin中有数据则将stdin内数据输出,若有客户端连接则向客户端发送信息
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define N 64
int main(int argc, const char *argv[])
{
int i, listenfd, connfd, maxfd;
char buf[N];
fd_set rdfs;
struct sockaddr_in myaddr;
if(argc<3)
{
printf("too few argument\n");
printf("Usage: %s <ip> <port>\n",argv[1]);
exit(0);
}
if((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror("fail to socket");
exit(-1);
}
bzero(&myaddr, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_port = htons(atoi(argv[2]));
myaddr.sin_addr.s_addr = inet_addr(argv[1]);
if (bind(listenfd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0)
{
perror("fail to bind");
exit(-1);
}
listen(listenfd, 5);
maxfd = listenfd;//由于stdin的文件描述符为0,所以maxfd是listenfd
while(1)
{
FD_ZERO(&rdfs);//使用之前先清空rdfs
FD_SET(0, &rdfs);//将stdin加入rdfs
FD_SET(listenfd, &rdfs);//将listenfd加入rdfs
//select()会修改传入的值,因此每次都需要重新设置这三个函数
if (select(maxfd+1, &rdfs, NULL, NULL, NULL) < 0)//超时时间设定为NULL
{
perror("fail to select");
exit(-1);
}
for(i=0; i<=maxfd; i++)
{
if (FD_ISSET(i, &rdfs))//监控到数据,查看数据来源
{
if (i == STDIN_FILENO)//来自stdin
{
printf("STDIN is coming\n");
fgets(buf, N, stdin);
printf("string:%s", buf);
}
else if (i == listenfd)//来自客户端
{
connfd = accept(i, NULL, NULL);
printf("New Connection connfd=%d is coming\n", connfd);
strcpy(buf,"Hello, client");
send(connfd,buf,N,0);
bzero(buf, N);
close(connfd);
}
}
}
}
return 0;
}
2.poll()函数与epoll()函数
select()是最早实现IO多路复用的函数,但是它暴露出了许多问题:
-select()有可能修改传入参数,这样对于一个需要多次调用的函数是非常不友好的(每次都需要重新设定)
-select()仅仅会返回,但不会告知哪个文件描述符得到了数据,因此需要FD_ISSET()筛选。如果文件描述符过多则十分浪费时间
-select()最多只能监控1024个文件描述符(且无法更改)
-select()不是线程安全的。如果将一个fd加入select()且突然想要关闭该文件描述符,则程序会崩溃
1997年,poll()函数被发明。poll()修复了select()的一些问题,例如:
-poll()去除了监控上限1024个
-poll()不再修改传入的数据
poll()的用法:
函数poll()
所需头文件:#include<poll.h>
函数原型:int poll(struct pollfd *fds, nfds_t nfds, int timeout);
函数参数:
fds 需要参与测试的文件描述符数组。该参数是一个struct pollfd类型的结构体数组,该结构体如下:
struct pollfd
{
int fd; //需要测试的文件描述符
short events; //需要测试的事件
short revents; //实际发生的事件(即返回结果)
}
events与revents的取值如下:
POLLIN 是否有数据可读
POLLOUT 是否有数据可写
POLLERR 是否发生错误(仅能检测输出用文件描述符)
POLLHUP 是否被挂起(Hang up,仅能检测输出用文件描述符)
POLLNVALfd 是否是一个打开的文件(仅能检测输出用文件描述符)
nfds fds数组内结构体的数量
timeout 超时时间,单位为毫秒
函数返回值:
成功:>0 数组fds中检测成功的文件描述符的总数量
失败:
0 超时
-1 错误
但是poll()仍然不是线程安全的。于是2002年,David Libenzi发明了poll()的取代方案epoll()
epoll()可以视为多路IO复用的最终实现,它修复了select()和poll()的绝大多数问题,例如:
-epoll()是线程安全的,因此无需考虑线程互斥
-在没有数据准备就绪时,epoll()不会轮询(即不占用CPU),而select()则占用CPU
-epoll()不仅返回有数据准备就绪,还直接返回对应的文件描述符,因此无需额外的选择代码
//注意:epoll()并不是一个函数而是一个函数族,包括epoll_create()、epoll_create1()、epoll_ctl()、epoll_wait()等函数
//epoll()本质是添加了信号驱动型IO的多路复用,在内核中使用mmap共享用户空间与内核空间,并使用回调函数。在这里不再展开描述
//epoll()唯一的缺点是只有Linux可用......