C++ TCP接收未知缓冲区大小

5
我想使用函数recv(socket, buf, len, flags)来接收传入的数据包,但是在运行时我并不知道这个数据包的长度,因此前8个字节应该告诉我这个数据包的长度。我不想随意分配一个大的len来解决这个问题,所以能否将len = 8,让bufuint64_t类型。然后之后用memcpy(dest, &buf, buf)来复制?

为什么不想分配一个任意大的缓冲区?如果数据不会长时间保留,那么缓冲区有多大并不重要。如果数据将在一段时间内保留,那么知道大小后将其复制到另一个缓冲区中有什么问题呢? - David Schwartz
请注意,使用8字节长度字段时,要么上4个字节始终为零(因此未使用/无意义),要么您偶尔会分配超过4GB长的接收缓冲区(可能远远超过!)。我不确定哪种可能性更糟糕 :) - Jeremy Friesner
4个回答

5

由于TCP是基于流的,我不确定你指的是什么类型的数据包。我会假设你是指应用程序级别的数据包。我的意思是这些数据包是由你的应用程序定义的,而不是由底层协议如TCP定义的。为了避免混淆,我将它们称为消息

我将展示两种可能性。首先,我将展示如何在读取完成之前不知道消息长度的情况下读取消息。第二个例子将进行两次调用。首先它读取消息的大小。然后一次性读取整个消息。


读取数据直到消息完整

由于TCP是基于流的,当您的缓冲区不够大时,您不会丢失任何数据。因此,您可以读取固定数量的字节。如果有数据缺失,您可以再次调用recv。这里是一个详细的例子。我只是写了一下,没有测试过。希望一切都能正常工作。

std::size_t offset = 0;
std::vector<char> buf(512);

std::vector<char> readMessage() {
    while (true) {
        ssize_t ret = recv(fd, buf.data() + offset, buf.size() - offset, 0);
        if (ret < 0) {
            if (errno == EINTR) {
                // Interrupted, just try again ...
                continue;
            } else {
                // Error occured. Throw exception.
                throw IOException(strerror(errno));
            }
        } else if (ret == 0) {
            // No data available anymore.
            if (offset == 0) {
                // Client did just close the connection
                return std::vector<char>(); // return empty vector
            } else {
                // Client did close connection while sending package?
                // It is not a clean shutdown. Throw exception.
                throw ProtocolException("Unexpected end of stream");
            }
        } else if (isMessageComplete(buf)) {
            // Message is complete.
            buf.resize(offset + ret); // Truncate buffer
            std::vector<char> msg = std::move(buf);
            std::size_t msgLen = getSizeOfMessage(msg);
            if (msg.size() > msgLen) {
                // msg already contains the beginning of the next message.
                // write it back to buf
                buf.resize(msg.size() - msgLen)
                std::memcpy(buf.data(), msg.data() + msgLen, msg.size() - msgLen);
                msg.resize(msgLen);
            }
            buf.resize(std::max(2*buf.size(), 512)) // prepare buffer for next message
            return msg;
        } else {
            // Message is not complete right now. Read more...
            offset += ret;
            buf.resize(std::max(buf.size(), 2 * offset)); // double available memory
        }
    }
}

你需要自己定义bool isMessageComplete(std::vector<char>)std::size_t getSizeOfMessage(std::vector<char>)

读取头文件并检查包的长度

第二种可能性是先读取头文件,只需8个字节,其中包含包的大小。之后,你就知道了包的大小。这意味着你可以分配足够的存储空间,并一次性读取整个消息:

/// Reads n bytes from fd.
bool readNBytes(int fd, void *buf, std::size_t n) {
    std::size_t offset = 0;
    char *cbuf = reinterpret_cast<char*>(buf);
    while (true) {
        ssize_t ret = recv(fd, cbuf + offset, n - offset, MSG_WAITALL);
        if (ret < 0) {
            if (errno != EINTR) {
                // Error occurred
                throw IOException(strerror(errno));
            }
        } else if (ret == 0) {
            // No data available anymore
            if (offset == 0) return false;
            else             throw ProtocolException("Unexpected end of stream");
        } else if (offset + ret == n) {
            // All n bytes read
            return true;
        } else {
            offset += ret;
        }
    }
}

/// Reads message from fd
std::vector<char> readMessage(int fd) {
    std::uint64_t size;
    if (readNBytes(fd, &size, sizeof(size))) {
        std::vector buf(size);
        if (readNBytes(fd, buf.data(), size)) {
            return buf;
        } else {
            throw ProtocolException("Unexpected end of stream");
        }
    } else {
        // connection was closed
        return std::vector<char>();
    }
}
MSG_WAITALL标志要求函数阻塞,直到完整的数据可用。但是,您不能依赖它。您必须检查它,并在缺少某些内容时再次读取。就像我上面所做的一样。 readNBytes(fd, buf, n)读取 n 字节。只要连接没有从另一端关闭,该函数将不会返回而不读取 n 字节。如果连接已由另一端关闭,则该函数返回false。如果在传输消息过程中连接被关闭,则会抛出异常。如果发生输入/输出错误,则会抛出另一个异常。 readMessage 读取8个字节 [sizeof(std::unit64_t)] 并将其用作下一个消息的大小。然后它读取消息。
如果您想要跨平台支持,应该将size转换为定义的字节顺序。计算机(使用x86架构)使用小端字节序。在网络传输中使用大端字节序是常见的。 注意: 使用MSG_PEEK可以实现UDP的此功能。您可以在使用此标志时请求标头。然后,您可以为整个包分配足够的空间。

2
一种常见的技术是先读取领先的消息长度字段,然后发出一个精确大小的预期消息的读取请求。
但是!不要假设第一次读取会给你所有八个字节(请参阅注释),或者第二次读取会为您提供整个消息/数据包。
您必须始终检查读取的字节数,并发出另一个读取请求(或两个(或三个,或...))以获取所需的所有数据。
注意:由于TCP是流传输协议,并且因为“在网络上”的数据包大小根据旨在最大化网络性能的非常神秘的算法而变化,您可能会轻松地发出八个字节的读取请求,但是结果只返回了三个(或七个等)。保证除非存在无法恢复的错误,否则您将收到至少一个字节和最多请求的字节数。因此,您必须准备好进行字节地址算术并在重复循环中发出所有读取请求,直到返回所需数量的字节为止。

抱歉,您能否澄清一下您所说的不要假设第一次读取会给您所有八个字节的含义?我正在编写这个作为练习的一部分,并且我可以期望所有传入的数据包的前8个字节是数据包的长度字段。 - Math is Hard
@MathisHard 我编辑了答案并添加了一条注释,解释了为什么即使在读取消息长度标头时,您可能会收到比您请求的数据少的字节数。 - Dale Wilson
@MathisHard 你不应该对数据包的格式有任何特定的期望。即使你有,你也不应该对数据包如何映射到recv调用有任何特定的期望。TCP提供了一个双向字节流给应用程序,而不是一个数据包或消息接口。 - David Schwartz

1

由于TCP是流式的,你接收到的数据没有真正的终点,除非连接关闭或出现错误。

因此,你需要在TCP上实现自己的协议,其中包含特定的消息结束标记、数据长度头字段或可能的基于命令的协议,其中每个命令的数据都具有已知的大小。

这样,你就可以读入一个小的固定大小的缓冲区,并根据需要将其添加到一个较大的(可能会扩展)缓冲区中。在C++中,“可能扩展”的部分非常容易,因为有 std::vectorstd::string(取决于你所拥有的数据)。

还有一件重要的事情要记住,由于TCP是基于流的,单个readrecv调用可能并不实际获取你请求的所有数据。你需要循环接收数据,直到你接收到所有数据。


像这样 while( recv(socket, buf, len, flags) > 0) {...} - Math is Hard
@MathisHard 是的,大致是这样。 - Some programmer dude
那么你可以先读入一个小的固定大小的缓冲区,然后根据需要将其追加到一个更大的(可能是可扩展的)缓冲区中。是啊,为什么不把每个数据块都复制两次呢?但为什么要止步于此呢?让我们把它复制三次! - SergeyA
或者您可以读取小的(可能是固定大小的)缓冲区,并将它们收集到一个std::vector中,可能还存储一些标志以检测消息何时完成,然后只需迭代整个向量处理整个消息。因此,不需要附加字节和调整字节缓冲区的大小(尽管处理消息可能变得不那么直观;具体取决于您想要对字节执行什么操作)。 - JustAMartin

0
在我个人看来,
我建议先接收“消息大小”(整数4字节固定)。
recv(socket,“以整数形式编写的消息大小”,“整数大小”)
然后
接收真正的消息。
recv(socket,“真实消息”,“以整数形式编写的消息大小”)
这种技术也可以用于“发送文件、图像、长消息”。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接