Unix网络编程学习笔记之第8章 基于UDP套接字编程

时间:2022-05-01 18:01:18

一、 UDP C/S的典型函数调用

Unix网络编程学习笔记之第8章 基于UDP套接字编程

UDP没有像TCP那样的连接,客户端直接sendto向某服务器发送数据,服务器端一直recvfrom阻塞,以接收任何客户端发送的数据。

 

二、 sendto和recvfrom函数

int sendto(int sockfd, const void* buff, size_t nbytes, int flag, const struct sockaddr* to, socklen_taddrlen);
int recvfrom(int sockfd, void* buff, size_t nbytes, int flag, struct sockaddr* from, socklen_t* addrlen);

1. 成功返回字节数,失败返回-1

2. 这两个函数相比较于read和write多了三个参数。

(1) flag后面说,这里先置为0

(2) sendto的地址结构指明发送目的地的套接字地址。addrlen指明地址长度,为整数型。相当于TCP的connect中的套接字地址。

(3) recvfrom的地址结构指明发送此数据报的发送端的套接字地址。addrlen为此套接字地址,为整型地址。相当于TCP的accept中的套接字地址。

3. 写一个长度为0的数据报是可行的,会形成一个只包含IP首部(20字节)和UDP首部(8字节)的IP数据报。所以recvfrom返回0,是可接受的。而不是像TCP那样read返回0表示关闭连接。

4. recvfrom的套接字地址参数可以是NULL,表示不关心数据是谁发的。此时addrlen也必须是NULL。

 

三、使用UDP书写回射服务器

#include     "unp.h"
#define MAXLINE 1024
#define PORT 13
void err_sys(const char* s)
{
    fprintf(stderr, "%s\n", s);
    exit(1);
}
void str_echo(int sockfd, struct sockaddr* cliaddr,socklen_t len)
{
    int nbyte;
socklen_t n;
char buff[MAXLINE];
    while(true)
    {
        n=len;
       nbyte=recvfrom(sockfd, buff, MAXLINE, 0, cliaddr, &n);
        if(nbyte<0)
            err_sys("recv error");
        sendto(sockfd, buff, nbyte, 0, cliaddr, n);
    }
}
int main(int argc, char** argv)
{
    int sockfd;
    struct sockaddr_in servaddr,cliaddr;
   sockfd=socket(AF_INET, SOCK_DGRAM, 0);
   
   bzero(&servaddr,sizeof(servaddr));
   servaddr.sin_family=AF_INET;
   servaddr.port=htona(PORT);
   servaddr.sin_addr.s_addr=htonl(INADDR_ANY);
   
    bind(sockfd,(struct sockaddr*) &servaddr, sizeof(servaddr));
    str_echo(sockfd,(struct aockaddr*)&cliaddr, sizeof(cliaddr));
}

1. 首先正常情况下,函数永不会终止。它不像TCP连接那样,还有终止连接的四次。

2. 处理函数str_echo,是一个迭代函数,不像TCP那样是并发的。一般TCP服务器都是并发的,而UDP服务器都是迭代的。为何?下面说

3. 每个UDP套接字都会有一个接收缓冲区,类似于一个队列。多个数据报到达UDP服务器,则会排队,调用recvfrom函数,从这个队列头取出数据报给进程。而TCP是为每个客户一个连接fork一个子进程,并且每个连接一个套接字,每个套接字一个接收缓冲区,所以我们要并发监听每个接收缓冲区。而UDP是任何客户发送的数据报放入一个接收缓冲区,所以根本无需什么并发服务器,也不可能做成并发的。

4. str_echo函数是协议无关的。

 

四、 使用UDP重写回射客户端

#include     "unp.h"
#define MAXLINE 1024
#define PORT 13
void err_sys(const char* s)
{
    fprintf(stderr, "%s\n", s);
    exit(1);
}
void str_cli( int sockfd, FILE* fd,const struct sockaddr* servaddr,socklen_t addrlen)
{
    int nbytes;
    charbuff[MAXLINE],recvbuff[MAXLINE];
    while(fgets(buff,MAXLINE,fd)!=NULL)
    {
        sendto(sockfd,buff, strlen(buff), 0, servaddr, addrlen);
       nbytes=recvfrom(sockfd,recvbuff, MAXLINE, 0, NULL, NULL );
        if(nbytes<0)
            err_sys("recvfrom error");
       recvbuff[nbytes]=0;
       fputs(recvbuff,stdout);
    }
}
int main(int argc, char** argv)
{
    int sockfd;
    struct sockaddr_inservaddr;
    char buff[MAXLINE+1];
    if(argc!=2)
        err_sys("input error");
    if((sockfd=socket(AF_INET,SOCK_DGRAM, 0))<0)
        err_sys("socket error");
   bzero(&servaddr, sizeof(servaddr));
   servaddr.sin_family=AF_INET;
   servaddr.sin_port=htons(PORT);
    if(inet_pton(AF_INET, argv[1],&servaddr.sin_addr)<=0)
        err_sys("ip address error");
   str_cli(sockfd,stdin,(struct sockaddr*) &servaddr,sizeof(servaddr));
    exit(0);
}

1. 没有为客户端指定本地端口,则客户端第一次sendto的时候,内核自动分配。

2. 这里recvfrom的套接字地址是空指针,这样做是非常危险的

因为可能任何主机给此客户端的这个临时端口发送一个消息时,此客户端会认为这个消息是从服务器端发送过来的。造成消息混乱。所以这个是有问题的,下面解决。

3. 数据报的丢失问题

如果某次客户端发送给服务器端的数据报丢失了,或者服务器端发给客户端的数据包丢失了,则这都会引起客户端永远阻塞在recvfrom函数上。

我们可以为recvfrom设置一个超时,但是超时还是不能完全解决这个问题。因为如果超时,我们不知道是客户端->服务器端数据报丢失了,还是服务器->客户端数据库丢失了。

 4. 针对上面2提到的问题,我们试着获取recvfrom的套接字地址和sendto发送的套接字地址是否一致,来决定此消息是否是来自对端服务器。

我们修改的str_cli函数如下:

void str_cli(int sockfd, FILE* fd, const struct sockaddr* servaddr, socklen_t addrlen)
{
    int nbytes;
    charbuff[MAXLINE],recvbuff[MAXLINE];
    struct sockaddr * fromaddr=new sockaddr();
    socklen_tfromaddrlen=addrlen;
    while(fgets(buff,MAXLINE,fd)!=NULL)
    {
        sendto(sockfd, buff,strlen(buff), 0, servaddr, addrlen);
        nbytes=recvfrom(sockfd,recvbuff, MAXLINE,0, fromaddr, &fromaddrlen );
        if(fromaddrlen !=addrlen || memcmp(servaddr, fromaddr, addrlen)!=0)
        {
            fputs("not from server, ignored",stdout);
            continue;
        }
        if(nbytes<0)
            err_sys("recvfrom error");
       recvbuff[nbytes]=0;
       fputs(recvbuff,stdout);
    }
}


可以看出,我们就是比较sendto时,服务器的套接字地址,和recvfrom得到的服务器套接字地址是否相等。这里我们先比较两者的长度,然后再逐字节比较。

这里还是有问题的:

如果服务器是多宿主机,即两个IP地址,如Ip1,Ip2。由于我们在写服务器端程序时,bind函数的参数是通配IP,所以当我们sendto时,是使用Ip1,而服务器回射时,内核自动选择了Ip2,则这会让我们客户端误判该回射消息不是来自服务器端。

两个解决办法:

1>  recvfrom得到IP后,查询DNS获得主机的域名,以判断消息是否来自该主机。

2>服务器端为每个IP创建一个套接字,使用bind到每个IP地址。然后使用select监听这些套接字,等待其中一个变为可读,说明客户端使用的是这个IP,则服务器使用这个IP套接字回射就可以了。

 

5. 服务器未运行

当我们先启动客户端,不启动服务器端时,发生了什么:

我们从控制台输入一行数据回车,然后客户端将永远阻塞在recvfrom函数上。

底层机制:

数据发送到服务器主机上,发送主机的目的端口并没有开启,所以返回一个端口不可达的ICMP消息,这个消息是不会返回客户端进程的(为何?)。所以客户端用于阻塞在recvfrom上。

异步错误:本例中错误由sendto函数引起,但是sendto成功返回,ICMP到后来才返回错误。这就是异步错误。

一个基本规则:对于一个UDP套接字,由它引发的异步错误不返回给它,除非它已经连接。(注意UDP也有connect函数)。

 

为何ICMP消息不会返回客户端进程?Unix这样设计的道理是什么?

假设我们使用客户端连续发送3个消息,2个消息的目的服务器正常,最后一个服务器未启动,则会有一个ICMP消息,假设这个消息被recvfrom获取,recvfrom返回一个负值表示错误,然后errno为错误类型。但是注意此时客户端并不知道是哪个目的服务器,哪个目的套接字出错,内核无法告知进程,因为recvfrom此时返回的信息只是errno,所以Unix规定,不返回给进程。

 

6. 给UDP套接字使用connect

上面提到未连接UDP套接字发生的异步错误,不会返回给进程,这里我们可以使用connect对一个UDP套接字进行连接。

Connect函数的调用和TCP一样,参数指定目的服务器的套接字地址。注意没有三次握手,只是检查对端是否存在立即可知的错误(如目的主机不可达)。

已连接UDP套接字和未连接UDP套接字的不同:

(1) 已连接套接字,直接使用send,发送数据报给connect的目的服务器套接字。而不使用sendto。

(2) 已连接套接字,直接使用write,接收来自connect目的服务器套接字的数据报,来自其他目的服务器套接字的数据报不会递交给该套接字。

也就意味着,已连接套接字只能和一个对端进行通信。

而未连接套接字显然可以和任何多个对端通信。

(3) 已连接套接字发生异步消息会返回给进程,因为此时已经知道目的套接字。而未连接套接字不会返回给进程。

 

注意:一般对客户端的UDP套接字进行connect,而服务器端还是sendto和recvfrom,connect只会影响本地套接字。

 

注意:我们可以对一个已连接UDP套接字指向多次connect,而TCP不可以。以下情况多次connect

(1) 指定新的IP地址和端口号

(2) 断开套接字。此时把套接字地址结构的地址族(IPv4的sin_family)设为AF_UNSPEC就可以了。

 

7. 性能

当我们对一个未连接的UDP套接字连续sendto两次,看看具体步骤:

连接套接字

发送第一个数据报

断开套接字

连接套接字

发送第二个数据报

断开套接字

如果两个数据报是同一个目的套接字,则我们应该使用显然connect,之后会提高效率。因为这样只需要一个连接和断开。

Unix中一个连接需要耗费一次UDP传输的三分之一的开销。

 

8. 我们修订上面的客户端str_cli函数

void str_cli( int sockfd, FILE* fd,const struct sockaddr* servaddr, socklen_t addrlen)
{
    int nbytes;
    char buff[MAXLINE],recvbuff[MAXLINE];
       conect(sockfd,servaddr,addrlen);
    while(fgets(buff,MAXLINE,fd)!=NULL)
    {
        write(sockfd, buff, strlen(buff));
        nbytes=read(sockfd,recvbuff, MAXLINE );
        if(nbytes<0)
            err_sys("read error");
       recvbuff[nbytes]=0;
       fputs(recvbuff,stdout);
    }
}


此时我们再运行客户端,输入一个未启动的服务器程序的主机Ip地址,然后从控制台输入一行数据,输出的结果就是readerror。这样异步错误返回给进程了。

注意:此时我们connect时,并没有发生错误,直到我们发送一个消息时才返回错误。而如果此时TCP的话,在connect时就会发生错误。原因?

因为UDP的connect不会触发三次握手,而TCP的connect会触发三次握手,发现目的端口不可达,则服务器会返回RST分组。

 

五、 UDP缺乏流量控制

假设一个客户端连续发送大量的数据,则服务器端使用套接字接收缓冲区排队接收这些数据,但当发送来的数据超出套接字接收缓冲区时,服务器端就会自动丢弃到来的数据报,而此时客户端和服务器端不会有任何的错误。

所以说UDP是没有流量控制的。

 

六、 UDP中的IP地址和端口号

1. 未连接的UDP套接字,如果我们没有bind,

则当sendto时,内核选择一个本地IP地址和端口号,所以同一主机上两次连续的sendto,两个消息的源IP地址和端口号可能都不一样。

而且,服务器端接收recvfrom后,回射消息,sendto时,可能造成回射消息的源IP地址和端口号和recvfrom消息的目的IP地址和端口号不一样。

2. 已连接UDP套接字,如果没有bind

同一个客户端,多次connect同一个目的IP端口号时,已连接套接字的本地IP和端口号可能都是不一样的。

 

我们可以使用getsockname来获取已连接UDP套接字的本地IP和端口号。

我们使用recvmsg来获取未连接的UDP套接字的本地IP和端口号。

 

七、我们使用select的TCP和UDP来重写回射服务器

#include "unp"
#define MAXLINE 1024
#define PORT 13
#define MAXCON 5
void err_sys(const char* s)
{
    fprintf(stderr, "%s\n", s);
    exit(1);
}
void str_echo(int sockfd)
{
    int nbyte;
    char buff[MAXLINE];
again:
       while((nbyte=read(sockfd, buff, MAXLINE)>0))
       {
             write(sockfd, buff, nbyte);
    }
       if(nbyte<0 &&errno==EINTR)
             goto again;
       else if(nbyte<0)
             err_sys("read error");
}
void sig_chld(int);
int main(int argc, char** argv)
{
    int listenfd, connfd, udpfd;
       pid_tchildpid;
       char recvmsg[MAXLINE];
       const int on=1;
    struct sockaddr_in servaddr,cliaddr;
       socklen_t len;
   listenfd=socket(AF_INET, SOCK_STREAM, 0);
   
    bzero(&servaddr,sizeof(servaddr));
   servaddr.sin_family=AF_INET;
   servaddr.port=htons(PORT);
   servaddr.sin_addr.s_addr=htonl(INADDR_ANY);
   setsockopt(listenfd,SOL_SOCKET, SO_REUSEADDR, &on,sizeof(on));
    bind(listenfd, (struct sockaddr*) &servaddr, sizeof(servaddr));
      
       listen(listenfd,MAXCON);
 
       udpfd=socket(AF_INET,SOCK_DGRAM, 0);
       bind(udpfd, (struct sockaddr*) &servaddr, sizeof(servaddr));
 
       fd_set fset;
       FD_ZERO(&fset);
       int maxfdp1=max(listenfd,udpfd)+1;
       int nready;
 
       signal(SIGCHLD,sig_chld);
       while(true)
       {
             FD_SET(listenfd,&fset);
             FD_SET(udpfd,&fset);
             nready=select(maxfdp1,fset,NULL,NULL,NULL);
             if(nready<0)
             {
                    if(errno==EINTR)
                           continue;
                    else
                           err_sys("select error");
             }
             if(FD_ISSET(listenfd,&fset))
             {
                    connfd=accept(listenfd,NULL,NULL);
                    if((childpid=fork())==0){
                           close(listenfd);
                           str_echo(connfd);
                           exit(0);
                    }
                    close(connfd);
             }
             if(FD_ISSET(udpfd, &fset))
             {
                    int nbytes;
                    len=siezof(cliaddr);
                    nbytes=recvfrom(udpfd,recvmsg, MAXLINE,0, (struct sockaddr*)&cliaddr,&len);
                    sendto(udpfd,recvmsg,nbytes,0, (struct sockaddr*)&cliaddr,len);
             }
       }
}


这里涉及select的IO复用,信号处理,fork子进程,TCP服务器,UDP服务器,套接字选项。

这里的str_echo函数和第五章的相同,信号处理函数并没有实现,注意函数里要调用waitpid。

 

注意这里有意思的地方:就是我们只是使用select监听TCP的监听套接字和UDP套接字。对于已连接的TCP套接字使用子进程来处理。

即TCP的并行服务器和UDP的迭代服务器。

前面我们都是使用select来监听TCP监听套接字和已连接套接字,使得程序完全的单进程。这里并没有这么做,因为那样太麻烦,这里只是展示了使用select同时监听TCP连接和UDP连接。很简洁,很有意思,这段代码仔细看。很多有意思的地方。

 

注意这里:同一台服务器,TCP套接字和UDP套接字使用同一个端口,这是可以的。