4.TCP读写

136 阅读5分钟

发送数据

发送数据时常用的有三个函数,分别是 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 写入的字节数有可能比请求的数量少,这在普通文件描述符情况下是不正常的。

产生这个现象的原因在于内核为读取和发送数据做了很多我们表面上看不到的工作。接下来拿 write 函数举例,重点阐述发送缓冲区的概念。

发送缓冲区

TCP 连接成功建立后,内核会为每一个连接创建配套的基础设施,比如发送缓冲区

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

这里有几种情况:

  1. 发送缓冲区足够大,可以直接容纳这份数据,程序从 write 调用中退出,返回写入的字节数就是应用程序的数据大小。
  2. 发送缓冲区不足以容纳应用程序数据,应用程序被阻塞,也就是说应用程序在 write 函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思。

那么什么时候才会返回呢?

实际上,每个内核的处理是不同的。大部分 UNIX 系统的做法是等到可以把应用程序数据完全放到内核的发送缓冲区中,再从系统调用中返回。 image.png

读取数据

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

read 函数

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

read 函数要求内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。返回值为实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF,这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。

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

// common.h文件,以后经常调用,直接 # include "common.h"
#include    <stdio.h>
#include    <sys/socket.h> 
#include <netinet/in.h>
#include <string.h>
# include <errno.h>

size_t readn(int fd, void *vptr, size_t n){
    size_t nleft;  // 想读取的数量
    ssize_t nread; // 一次读到的数量
    char *ptr; // 缓冲区
    ptr = vptr;
    nleft = n;

    while (nleft > 0){
        if((nread = read(fd, ptr, nleft)) < 0){
            if (errno == EINTR){
                nread = 0;
            }else return (-1);
        }else if (nread == 0) break; // EOF

        nleft -= nread;
        ptr += nread;  
    }
    return n - nleft;
}

缓冲区实验

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

服务器端读取数据程序

# include "common.h"

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;

        time++;
        fprintf(stdout, "1K read for %d \n", time);
        usleep(1000);
    }
}


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, (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);          /* 关闭连接套接字,注意不是监听套接字*/
    }
}

总的来说就是创建socket后bind端口,最后监听。每次创建连接后通过read_data函数循环读取,每次读取1024字节直到读完。

客户端发送数据程序

# include "common.h"

# define MESSAGE_SIZE 10240000

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

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

    return;
}

int main(int argc, char **argv) {
    int sockfd;
    struct sockaddr_in servaddr;

    if (argc != 2)
        error(1, 0, "usage: tcpclient <IPaddress>");

    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, argv[1], &servaddr.sin_addr);
    int connect_rt = connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }
    send_data(sockfd);
    exit(0);
}

一套操作后向缓冲区一次性发送102400字节。

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

客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:

image.png

而客户端直到最后所有的字节流发送完毕才打印出下面的一句话,在此之前 send 函数一直都是阻塞的,也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。

实验二: 服务端处理变慢

如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从从 10240000 调整为 1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:

image.png

但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。

通过这个例子我想再次强调一下:

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