c linux网络编程原理讲解(七) UDP协议探索

这篇文章主要讲解udp编程和tcp编程的异同点。讲解自己在写代码过程中遇到的一些问题和理解。
最后实现一个udp client 和一个udp server ,用epoll IO复用的框架完成对多个client消息的处理与连接。

UDP和TCP的区别

TCP传输有几个特征

1.数据在传输过程中不会消失

因为TCP是按字节传输可以将TCP的字节流比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;

2.数据是按照顺序传输的;

同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。tcp的重传和确认机制会保证字节流的顺序是一定正确的。应用层处理时只负责从网络协议栈里读就行了。

3.数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。

TCP协议栈的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端在应用层只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。

UDP传输有以下几个特征:

1.强调快速传输而非传输顺序

UDP传输是面向报文的,包的先后顺序不保证,可能先发出去的包后收到。所以在应用层要自己做好包的顺序排列。

2.传输的数据可能丢失也可能损毁;

UDP传输没有确认机制,所以不管包到没到,发送端都不管了。所以要想让数据不出现丢失

3.限制每次传输的数据大小;

4.数据的发送和接收是同步的(有的教程也称“存在数据边界”)。

因为是面向报文的服务,所以发送端发送一个报文,接收端就会接受一个报文。

代码编写

代码放在github

服务端

首先我们创建listenfd用来监听,并且设置一些必要的属性

 //先创建listenfd
    if((listenfd=socket(PF_INET,SOCK_DGRAM,0))==-1){
        perror("socket");
        exit(1);
    }
    //设置一些属性,快速重连和多线程监听同一端口
    ret=setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
    if(ret){
        exit(1);
    }
    ret=setsockopt(listenfd,SOL_SOCKET,SO_REUSEPORT, &opt, sizeof(opt));
    if(ret){
        exit(1);
    }
    //设置非阻塞
    int flags=fcntl(listenfd,F_GETFL,0);
    flags |= O_NONBLOCK;
	fcntl(listenfd, F_SETFL, flags);

1.PF_INET和AF_INET有时候会混用的问题
AF = Address Family
PF = Protocol Family
AF_INET = PF_INET
所以,理论上建立socket时是指定协议,应该用PF_xxxx,设置地址时应该用AF_xxxx。当然AF_INET和PF_INET的值是相同的,混用也不会有太大的问题。

2.SO_REUSEADDR和SO_REUSEPORT

  1. SO_REUSEADDR:这个选项允许在同一端口上快速重用处于TIME_WAIT状态的套接字。当一个套接字关闭后,它会进入TIME_WAIT状态一段时间,以确保所有的数据包都被正确处理。设置SO_REUSEADDR选项后,可以立即重新使用相同的端口进行绑定。这对于服务器程序在关闭后快速重新启动非常有用。
  2. SO_REUSEPORT:这个选项允许多个套接字绑定到相同的端口上。在多线程或多进程服务器中,可以使用SO_REUSEPORT选项来实现负载均衡,让多个进程或线程同时监听相同的端口。这样,当有新的连接到达时,操作系统会将连接分发到不同的套接字上,实现并发处理。

3.设置非阻塞是为了什么
我们一般使用IO复用来实现并发模型,如果我们默认监听套接字为阻塞模式,假设一种场景如下:

  • 客户通过connect向TCP服务器发起三次握手
  • 三次握手完成后,触发TCP服务器监听套接字的可读事件,IO复用返回(select、poll、epoll_wait)
  • 客户通过RST报文取消连接 TCP服务器调用accept接受连接,此时发现内核已连接队列为空(因为唯一的连接已被客户端取消)
  • 程序阻塞在accept调用,无法响应其它已连接套接字的事件 为了防止出现上面的场景,我们需要把监听套接字设置为非阻塞
    在这里插入图片描述

客户端发起connect后,客户端发送syn包。

服务器收到连接,poxi协议栈会在syn队列中加入新的节点,并且发送syn和ack,注意此时在服务端epoll_wait已经检测到可读事件了,会结束阻塞。然后调用accept。

此时客户端收到ack,如果发送了RST包,所以不会增加accept队列的节点,但是accept已经持续阻塞,由于IO复用是单线程,会导致线程一直阻塞在accept。

所以要设置非阻塞socket。

接着我们绑定IP

 //设置IP地址
    bzero(&my_addr,sizeof(my_addr));
    my_addr.sin_family=AF_INET;
    my_addr.sin_port = htons(port);
    my_addr.sin_addr.s_addr = INADDR_ANY;
    //调用bind绑定
     if (bind(listenfd, (struct sockaddr *) &my_addr, sizeof(struct sockaddr)) == -1) {
        perror("bind");
        exit(1);
    } else {
        printf("IP bind OK\n");
    }

注意UDP是没有listen和accept
因为 UDP 是无连接的,所以在编程时不需要调用 listen 函数和 accept 函数。

创建epoll池
将listenfd加入监听

// 创建epoll池
    epfd=epoll_create(1);
    //listenfd加入epoll监听
    ev.events= EPOLLIN | EPOLLET; //边沿触发
    ev.data.fd=listenfd; 
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) < 0) {
        fprintf(stderr, "epoll set insertion error: fd=%dn", listenfd);  //格式化输出错误信息
        return -1;
    } else {
        printf("ep add OK\n");
    }

这里我们设置了边沿触发

epoll循环 和tcp逻辑相似

 //进入epoll循环
    while(1){
        int nread=epoll_wait(epfd,events,10000,-1);
        if (nread== -1) { //出错
            perror("epoll_wait");
            break;
        }
        int i;
        for (i = 0; i < nread; ++i) {
            if (events[i].data.fd == listenfd) { //接受连接
                    int clientfd;
                    while(1){
                        clientfd=udp_accept(listenfd,my_addr);
                        if (clientfd == -1) break; 
                        //将clientfd加入监听
                        ev.events= EPOLLIN; //边沿触发
                        ev.data.fd=clientfd; 
                        if (epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev) < 0) {
                            fprintf(stderr, "epoll set insertion error: fd=%dn", clientfd);  //格式化输出错误信息
                            return -1;
                        } else {
                            printf("ep add OK\n");
                        }
                    }
				}
             else {
                //直接读消息
                read_data(events[i].data.fd);
            }
        }
    }

这里需要讲解的是
循环接受连接

 while(1){
  clientfd=udp_accept(listenfd,my_addr);
 }

因为之前我们设置了边沿触发

ev.events= EPOLLIN | EPOLLET; //边沿触发

所以在这里要把连接读完。否则会等到下一次连接发起才能读。

然后是udp_accept函数

int udp_accept(int listenfd,struct sockaddr_in my_addr){
    int new_sd = -1;
    int ret = 0;
    int reuse = 1;
    char buf[16];
    struct sockaddr_in peer_addr;  //对方的地址
    socklen_t cli_len = sizeof(peer_addr);
    //接收信息
    ret = recvfrom(listenfd, buf, 16, 0, (struct sockaddr *)&peer_addr, &cli_len);
    if (ret < 0) {
		return -1;
    }
    //创建一个新的socket
     if ((new_sd = socket(PF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("child socket");
        exit(1);
    } else {
        printf("%d, parent:%d  new:%d\n",count++, listenfd, new_sd); //1023
    }
     buf[ret]='\0';
     printf("ret: %d, buf: %s from %d\n ", ret, buf,new_sd);
    ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse));
    if (ret) {
        exit(1);
    }

    ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse));
    if (ret) {
        exit(1);
    }
    //绑定本地地址
    ret = bind(new_sd, (struct sockaddr *) &my_addr, sizeof(struct sockaddr));
    if (ret){
        perror("chid bind");
        exit(1);
    } else {
    }
    //设置地址的协议
    peer_addr.sin_family = PF_INET;
    if (connect(new_sd, (struct sockaddr *) &peer_addr, sizeof(struct sockaddr)) == -1) {
        perror("chid connect");
        exit(1);
    } else {
    }
    return new_sd;
}

需要注意,因为udp没有accept函数,所以我们在这里需要自己实现一个accept的功能。

我们首先调用recvfrom接收信息。拿到对方的IP地址

然后我们创建一个新的socket对象。

  • tcp的accept方法中,其实也是创建了新的socket对象,然后将该对象的fd返回。
  • 所以我们需要效仿TCP的做法
  • 紧接着我们也给它设置一些参数,绑定本地的地址。
  • 最后调用connect方法

这里对connect方法做说明:

udp_accept函数并没有真正调用connect函数来建立连接。它使用了connect函数的模拟行为

模拟行为是指: 为UDP套接字设置了一个默认的目的地,以便后续的数据发送操作可以使用简化的send函数。这样做是为了避免每次发送数据时都要指定目的地的地址信息。

connect函数的行为会根据套接字的类型来进行区分。在给定的代码中,connect函数的调用是针对UDP套接字(SOCK_DGRAM)进行的

在TCP套接字(SOCK_STREAM)上调用connect函数时,会建立一个持久的连接,连接的两端可以进行双向的数据交换。
但是,在UDP套接字上调用connect函数时,实际上并不会建立持久的连接。UDP是无连接的协议,每个数据报都是独立的。

接着是read_data函数

void read_data(int fd){
    char recvbuf[MAXBUF + 1];
    int  ret;
    struct sockaddr_in client_addr;
    socklen_t cli_len=sizeof(client_addr);

    bzero(recvbuf, MAXBUF + 1);
  
    ret = recvfrom(fd, recvbuf, MAXBUF, 0, (struct sockaddr *)&client_addr, &cli_len);
    if (ret > 0) {
        printf("read[%d]: %s  from  %d\n", ret, recvbuf, fd);
    } else {
        printf("read err:%s  %d\n", strerror(errno), ret);
    }
}

这里比较简单,寝室就是从fd调用recvfrom读数据

客户端

这里我直接贴代码 比较简单

就是从2025端口开始不断建立udp连接发包


void createClient(int id,int myPort,int peerPort){
  
    int reuse = 1;
      
    int socketFd;
    struct sockaddr_in peer_Addr;
    peer_Addr.sin_family = PF_INET;
    peer_Addr.sin_port = htons(peerPort);
    peer_Addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    struct sockaddr_in self_Addr;
    self_Addr.sin_family = PF_INET;
    self_Addr.sin_port = htons(myPort);
    self_Addr.sin_addr.s_addr = inet_addr("0.0.0.0"); 
    
    if ((socketFd = socket(PF_INET, SOCK_DGRAM| SOCK_CLOEXEC, 0)) == -1) {
        perror("child socket");
        exit(1);
    } 

    int opt=fcntl(socketFd,F_GETFL);
    fcntl(socketFd,F_SETFL,opt|O_NONBLOCK);

    if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))){
            exit(1);
    }
    if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))){
        exit(1);
    }
    
    if (bind(socketFd, (struct sockaddr *) &self_Addr, sizeof(struct sockaddr))){
        perror("chid bind");
        exit(1);
    } else {

    }
   
    if (connect(socketFd, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr)) == -1) {
        perror("chid connect");
        exit(1);
    }
   
    
    usleep(1); // --> key

    char buffer[1024] = {0};
    memset(buffer, 0, 1024);
    sprintf(buffer, "hello %d", id);
    sendto(socketFd, buffer, strlen(buffer), 0, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr_in));

}

void serial(int clinetNum){
    for(int i=1;i<=clinetNum;i++){
        createClient(i,2025+i,1234);
    }
}
int main(int argc, char * argv[])
{

	serial(1024);
	
    printf("serial success\n");
    return 0;
}

测试

最后是测试截图
在这里插入图片描述

在这里插入图片描述
因为最大打开数是1024个文件,代码在1023附近终止。

可以设置linux最大打开的句柄来增加连接数。这里就不再赘述。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值