(1) 发送数据

发送数据时常用的有三个函数,分别是 writesendsendmsg
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)
使用场景:
  • write:常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
  • send:可以通过 flag 参数指定选项,发送带外数据。(带外数据,是一种基于 TCP 协议的紧急数据,用于客户端 - 服务器在特定场景下的紧急处理)
  • senmsg:可以指定多重缓冲区传输数据,以结构体 msghdr 的方式发送数据。

发送缓冲区

当TCP三次握手成功,TCP连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区
发送缓冲区的大小可以通过套接字选项来改变
注:当我们的应用程序调用 write 函数时,实际所做的事情是 将数据从应用程序中 拷贝到 操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。

有俩种情况:
  • 第一种情况:操作系统内核的发送缓冲区足够大,可以直接容纳这份数据程序从 write 调用中返回,返回写入的字节数就是应用程序的数据大小。
  • 第二种情况:操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据操作系统内核并不会返回,也不会报错,而是应用程序被阻塞。(大部分UNIX系统的做法是 一直等到可以把应用程序数据 完全放到 操作系统内核的发送缓冲区中,再从系统调用中返回)
注:返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后 由操作系统内核通过网络发送出去。


(2) 读取数据

read函数

ssize_t read (int socketfd, void *buffer, size_t size)
read函数要求操作系统内核 从套接字描述符socketfd 读取最多多少个字节(size),并将结果存储到 buffer 中。
  • 如果返回值> 0:即实际读取的字节数目
  • 如果返回值= 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况
  • 如果返回值= -1,表示出错。(如果是非阻塞 I/O,情况会略有不同)

注:这里是最多读取 size 个字节。如果我们想让应用程序每次都读到 size 个字节,就需要不断地循环读取。
/* 从socketfd描述字中读取"size"个字节. */
size_t readn(int fd, void *buffer, size_t size) {
    char *buffer_pointer = buffer;
    int length = size;

    while (length > 0) {
        int result = read(fd, buffer_pointer, length);
        if (result < 0) {
            if (errno == EINTR)
                continue;       /* 考虑非阻塞的情况,这里需要再次调用read */
            else
                return (-1);
        } else if (result == 0)
            break;               /* EOF(End of File)表示套接字关闭 */

        length -= result;
        buffer_pointer += result;
    }

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

(3) 缓冲区实验

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

服务器端读取数据程序

#include "lib/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);        /* 关闭连接套接字,注意不是监听套接字*/
    }
}
21-35 行:先后创建了 socket 套接字,bind 到对应地址和端口,并开始调用 listen 接口监听。
38-42 行:循环等待连接,通过 accept 获取实际的连接,并开始读取数据。
8-15 行:  实际每次读取 1K 数据,之后休眠 1 秒,用来模拟服务器端处理时延。

客户端发送数据程序

#include "lib/common.h"

#define MESSAGE_SIZE 102400

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);
}
31-37 行:先后创建了 socket 套接字,调用 connect 向对应服务器端发起连接请求。
43 行:     在连接建立成功后,调用 send_data 发送数据。
6-11 行:  初始化了一个长度为 MESSAGE_SIZE 的字符串流。
16-25 行:调用 send 函数将 MESSAGE_SIZE 长度的字符串流发送出去。

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

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

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

实验二: 服务端处理变慢

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

但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
注:发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。

思考题

1. 既然缓冲区如此重要,我们可不可以把缓冲区搞得大大的,这样不就可以提高应用程序的吞吐量了么?这个方法可行吗?
无限增大缓冲区肯定不行,write函数发送数据只是将数据发送到内核缓冲区,而什么时候发送由内核决定。同时网络的传输大小MTU也会限制每次发送的大小,缓存区过大 最后由于数据堵塞需要消耗大量内存资源,资源使用效率不高。而且数据传输也是有延时要求的,不可能总是在缓冲区里等着。
2. 一段数据流从应用程序发送端,一直到应用程序接收端,总共经过了多少次拷贝?
不涉及协议栈层面,应该是4次用户缓冲区 -> 内核缓冲区 -> 网卡 -------> 对端网卡 -> 内核缓冲区 -> 用户缓冲区