Unix/Linux编程:socket阻塞式读写再理解

建立连接的根本目的是为了数据的收发。拿我们常用的网络场景举例子,我们在浏览商品或者购买货品的时候,并不会感觉到网络连接的存在,但是我们可以真切感觉到数据在客户端和服务端有效的传送,比如浏览商品信息的不断刷新,购买货品时显式购买成功的消息等。

首先我们来看一下发送数据

发送数据

发送数据时常用的有三个函数,分布是write、send、sendmsg

ssize_t write (int socketfd, const void *buffer, size_t size)
ssize_t send (int socketfd, const void *buffer, size_t size, int flags)
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)

每个函数都是单独使用的,使用的场景略有不同:

  • 第一个函数是常见的文件写函数,如果把socketfd换成文件描述符,就是普通的文件写入
  • 如果想指定选项,发送带外数据,就需要使用第二个待flag的函数。所谓带外数据,是一种基于TCP协议的紧急数据,用于客户端-服务器在特定场景下的紧急处理
  • 如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体msghdr的方式发送数据

问题:既然套接字描述符是一种特殊的描述符,那么在套接字描述符上调用write函数,应该和普通文件描述符上调用write函数的行为是一致的,都是通过描述符句柄写入指定的数据,他们有什么不同吗?

  • 对于普通的文件描述符而言,一个文件描述符代表打开的一个文件句柄,通过调用write函数,操作系统内核帮我们不断的往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数size的值是相同的,否则表示出错
  • 对于套接字描述符而言,它代表了一个双向连接,在套接字描述符上调用write写入的字节数有可能比请求的数量少,这在普通文件描述符情况下是不正常的。
  • 产生这个现象的原因在于操作系统内核为读取和发送数据做了我们表面上看不到的工作。比如说发送缓冲区

发送缓冲区

当TCP三次握手成功,TCP连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区

发送缓冲区的大小可以通过套接字选项来改变,当我们用应用程序调用write函数是,实际上所做的事情是把从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去

这里有几种情况:

  • 第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这部分数据,那么皆大欢喜,我们的程序从write调用中退出,返回写入的字节数就是应用程序的数据大小
  • 第二种情况是,操作系统内核的发送缓冲区足够大,不过还有数据没有发送完,但是操作系统内核的发送缓冲区不足以容纳应用程序数据。在这种情况下,你预料的结果是什么呢?报错?还是直接返回?
    • 操作系统内核并不会返回,也不会保存,而是应用程序被阻塞,也就是说应用程序在write函数调用出停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的
    • 那什么时候才会返回呢?实际上,每个操作系统内核的处理都是不同的。大部分Unix系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。怎么理解呢?
      • 操作系统是很聪明的,当TCP连接建立之后,它就开始运作起来。
      • 你可以把发送缓冲区想像成一条包裹流水线,有个聪明且忙碌的工人不断的从流水线上取出包裹(数据)
      • 这个工人会按照TCP/IP的语义,把取出的包裹(数据)封装成TCP的MSS包,以及IP的MTU包,最后走数据链路层将数据发送出去。
      • 这样我们的发送缓冲区就又空了一部分,于是又可以从应用程序搬一部分数据到发送缓冲区中,这样一直进行下去,到某一时刻,应用程序的数据可以完全放置到发送缓冲区中。
      • 这个时候,write阻塞调用返回。注意,返回的时候,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据在稍后由操作系统内核通过网络发送出去。
        在这里插入图片描述

读取数据

我们可以注意到,套接字描述本身和本地文件描述符并无区别,在UNIX世界里万物都是文件,这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括read、write等

让我们先从最简单的 read 函数开始看起,这个函数的原型如下:

ssize_t read (int socketfd, void *buffer, size_t size)

read函数要求操作系统内核从套接字描述符socketfd读取最多多少个字节(size),并将结果存储到buffer中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果,返回值为0,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况;如果返回值为-1,表示出错。当然,如果是非阻塞 I/O,情况会略有不同

注意这里是最多读取 size 个字节。如果我们想让应用程序每次都读到 size 个字节,就需要编写下面的函数,不断地循环读取。
在这里插入图片描述

  • 11-25 行的循环条件表示的是,在没读满 size 个字节之前,一直都要循环下去。
  • 13-14 行表示的是非阻塞 I/O 的情况下,没有数据可以读,需要继续调用 read。
  • 17-18 行表示读到对方发出的 FIN 包,表现形式是 EOF,此时需要关闭套接字。
  • 21-22 行,需要读取的字符数减少,缓存指针往下移动。
  • 24 行是在读取 EOF 跳出循环后,返回实际读取的字符数。

缓冲区实验

我们用一个客户端 - 服务器的例子来解释一下读取缓冲区和发送缓冲区的概念。在这个例子中客户端不断地发送数据,服务器端每读取一段数据之后进行休眠,以模拟实际业务处理所需要的时间。

下面是服务器端读取数据的程序:

#include <zconf.h>
#include <sys/socket.h>
#include <rpc/types.h>
#include <string.h>
#include <cstdio>
#include <errno.h>

void read_data(int sockfd);
ssize_t readn(int fd, void *vptr, size_t size);

int main(int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;

    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(12345);

    /* bind 到本地地址,端口为 12345 */
    bind(listenfd, (const struct sockaddr *)&servaddr, sizeof(servaddr));
    /* listen 的 backlog 为 1024 */
    listen(listenfd, 1024);

    /* 循环处理用户请求 */
    for ( ; ; ) {
        clilen = sizeof(cliaddr);
        connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &clilen);
        read_data(connfd); /* 读取数据 */
        close(connfd); /* 关闭连接套接字,注意不是监听套接字 */
    }
}

// 实际每次读取 1K 数据,之后休眠 1 秒,用来模拟服务器端处理时延。
void read_data(int sockfd)
{
    ssize_t n;
    char buf[1024];
    int time = 0;
    for ( ; ; ) {
        fprintf(stdout, "block in read\n");
        if ( (n = readn(sockfd, buf, 1024)) == 0)
            return; /* connection closed by other end */
        time ++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000);
    }
}

/* 从 socketfd 描述字中读取 "size" 个字节. */
ssize_t readn(int fd, void *vptr, size_t size)
{
    size_t nleft;
    ssize_t nread;
    char *ptr;
    ptr = (char *)(vptr);
    nleft = size;

    while (nleft > 0) {
        if ( (nread = read(fd, ptr, nleft)) < 0) {
            if (errno == EINTR)
                nread = 0; /* 这里需要再次调用 read */
            else
                return(-1);
        } else if (nread == 0)
            break; /* EOF(End of File) 表示套接字关闭 */
        nleft -= nread;
        ptr += nread;
    }
    return(size - nleft); /* 返回的是实际读取的字节数 */
}

下面是客户端发送数据的程序:

#include <zconf.h>
#include <sys/socket.h>
#include <rpc/types.h>
#include <string.h>
#include <cstdio>
#include <errno.h>
#include <malloc.h>
#include <hv/hdef.h>

void send_data(FILE *fp, int sockfd);
int main(int argc, char **argv)
{
    int sockfd;
    struct sockaddr_in servaddr;



    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(12345);
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
    connect(sockfd, (const struct sockaddr *) &servaddr, sizeof(servaddr));
    send_data(stdin, sockfd);
    exit(0);
}


# define MESSAGE_SIZE 10240000
void send_data(FILE *fp, int sockfd)
{
    char * query;
    query = (char *)malloc(MESSAGE_SIZE+1);
    for(int i=0; i< MESSAGE_SIZE; i++){
        query[i] = 'a';
    }
    query[MESSAGE_SIZE] = '\0';

    int remaining, n_written;
    const char *cp;
    cp = query;
     remaining = strlen(query);
    while (remaining) {
        n_written = send(sockfd, cp, remaining, 0);
        fprintf(stdout, "send into buffer %ld \n", n_written);
        if (n_written <= 0) {
            perror("send");
            return;
        }
        remaining -= n_written;
        cp += n_written;
    }
    return;
}

实验一: 观察客户端数据发送行为

客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程
在这里插入图片描述
而客户端直到最后所有的字节流发送完毕之后才打印出下面的一句话send into buffer 10240000,说明在此之前send函数一直都是阻塞的,也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的

实验二:服务端处理变慢

如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从从 10240000 调整为1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:
在这里插入图片描述
但于此同时,服务端读取程序还在屏幕上不停的打印读取数据的进度,显示出服务端读取程序还在辛苦的从缓冲区中读取数据。

也就是说:发送成功(write)仅仅表示数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到了所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓存所接收,对我们而言是完全透明的

总结

  • 对于send来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到
  • 对于read来说,需要循环读取数据,并且考虑EOF等异常条件。

问题:既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?

不行。write函数发送数据只是将数据发送到内核缓冲区,而什么时候发送有内核决定。

  • 内核缓冲区总是充满数据时会产生粘包问题
  • 网络的传输大小MTU也会限制每次发送的大小。
  • 由于数据阻塞需要消耗大量内存资源,资源使用效率不高

相当于让仓库变大,可以存储了更多的货物,如果出货的速度有限,会有更多的货物烂在仓库里。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值