在完成后续实验的设计和编码过程中,发现底层字节流的实现如果借助std::string来做的话,后续的很多工作将会带来很多不便。因此,我之后重新编码了可靠字节流部分,使用双端队列std::deque作为底层字节流缓冲区容器。
但是,我并不打算删除原有的博文,因为自己的孩子怎么看都觉得好,不好也好, 因为这里我有自己的看法:
诚然,使用queue或者deque会带来实现上的重大约简,但是要注意它们的本质都是容器适配器(adapter),它们依托于std::vector实现。std::vector为了减少数组扩张时频繁地发生数据拷贝,所以采用了相对激进的内存分配策略。如果你读过C++ Primer,应该对这件事有印象。简单来说,当你往vector里push元素时,它会预先分配更多的空间,这个值可以由capacity()方法获得。
我的迷惑之处就在此,为什么我会舍近求远使用std::string作为底层实现,本质就是可以通过取模运算保证缓冲区大小绝对不超过capacity。但是使用deque,当你真的向deque中填入capacity个元素时,它分配的空间可能是1.5capacity或者2capacity。
不过在往后做实验的时候发现,这里的capacity本质上是和TCP协议中的发送窗口密切关联的,所以这个地方为了省空间而额外做的工作确实值得再次权衡。下面是使用deque的实现版本,不再一一解释代码思路,应该非常浅显易懂:
// _Stream is std::deque
ByteStream::ByteStream(const size_t capacity) : _error(false), _endin(false),
_Stream(),
_Capacity(capacity),
_TotalWritten(0), _TotalRead(0){}
size_t ByteStream::write(const string &data) {
/*1.calculate the remain space of stream*/
size_t Remainder = this->remaining_capacity();
/*2.the byte can be written into stream is the smaller value of data.size() and the left space of stream*/
size_t ByteWrite = (data.size() >= Remainder) ? Remainder : data.size();
/*3.write the byte into stream one by one*/
size_t Counter = 0;
while(Counter < ByteWrite)
_Stream.push_back(data[Counter++]);
/*4.add the ByteWrite to TotalWritten*/
_TotalWritten += ByteWrite;
return ByteWrite;
}
/*----------------------------------------------*/
size_t ByteStream::remaining_capacity() const {
return _Capacity - _Stream.size();
}
/*----------------------------------------------*/
void ByteStream::pop_output(const size_t len) {
/*1.get how many bytes has not been read*/
size_t Remainder = this->buffer_size();
/*2.the number of byte can be popped is the smaller value of Remainder and len*/
size_t ByteRemoved = len >= Remainder ? Remainder : len;
/*3.modify the _ReadPtr to the appropriate position*/
size_t Counter = 0;
while(Counter++ < ByteRemoved)
_Stream.pop_front();
/*4.add the ByteRemoved to _TotalRead*/
_TotalRead += ByteRemoved;
}
/*----------------------------------------------*/
string ByteStream::peek_output(const size_t len) const {
string PeekStr = ""; // result
/*1.get how many bytes has not been read*/
size_t Remainder = this->buffer_size();
/*2.the number of byte can be peeked is the smaller value of Remainder and len*/
size_t BytePeek = len >= Remainder ? Remainder : len;
/*3.copy Byte to PeekStr while _ReadPtr do not move*/
size_t Counter = 0;
while(Counter < BytePeek)
PeekStr.push_back(_Stream[Counter++]);
return PeekStr;
}
/*----------------------------------------------*/
std::string ByteStream::read(const size_t len) {
/*combination of peek and pop*/
/*1.peek*/
string PeekStr = peek_output(len);
/*2.pop out the bytes from stream*/
pop_output(len);
return PeekStr;
}
/*----------------------------------------------*/
bool ByteStream::input_ended() const {
return _endin == true; // judge the _endin flag
}
/*----------------------------------------------*/
size_t ByteStream::buffer_size() const {
return _Stream.size();
}
/*----------------------------------------------*/
bool ByteStream::buffer_empty() const {
return _Stream.empty();
}
/*----------------------------------------------*/
bool ByteStream::eof() const {
return input_ended() && buffer_empty(); // if the input has ended and the buffer has nothing to be read
// that is the end of file
}
最早,为了获取空间上的极致约简,我使用了相对复杂的std::string,最初版的实现都详细地阐述在了原始版本的博客中,留作成长的纪念。
以下是原版博客和实现,完成于2022年3月。
我在开始动手做CS144的实验之前,首先结合中科大郑烇老师的《计算机网络》课程(哔哩哔哩上有《计算机网络》和《高级计算机网络》的全套教学视频课程,强烈推荐!)把《计算机网络——自顶向下方法》这本书过了一遍,夯实了一遍计算机网络的理论基础。(BTW,我在高新区食堂偶遇过郑烇老师 & 貌似录制高级计算机网络的教室也是高新区图书馆侧翼的教室)
在完成理论知识的学习之后,就必须要找一个具体的实验,通过写代码的方式来将理论知识落到实地。这里我选择的是Stanford的CS144的配套实验来完成,这个系列实验共分Lab0~Lab7共8个实验,从头到尾一步一步地实现一个简单的TCP/IP协议栈。
这是Stanford CS144系列实验的第一篇文章,希望这个学期能把Lab0~Lab7完成吧。CS144相关的课程资源在这里,在Lab Assignment中可以找到对应的实验说明文档,结合此文档可以开展实验。
这个系列仅表示本人在做实验过程中的思考和实现,并不保证答案的最优性,甚至会有谬误存在,如果需要交流请留言。如果涉及版权问题需要变更博客权限,请联系我([email protected])。
实验0的标题是热身,这个实验主要的内容是:
这一部分主要就是搭建实验环境,很简单,只需要按照手册一步步来就好,手册中提供了三种方式来搭建环境。
第四个是面向MacBook的特殊选项,其他三个选项可以任选,你可以按照选项1安装一个CS144官方定制的镜像,也可以像我一样按照选项2在已有的Ubuntu 18.04上运行配置脚本,或者在其他GNU/Linux发行版本上执行选项3(这个不推荐)。
这里安排个插入广告:我使用的设备搭载的操作系统是Windows 11,日常生活中需要用到Linux的场景我会优先使用WSL2.0(没错,我不是很喜欢VMware或者VirtualBox,我一直认为命令行是最优雅简洁高效的操作系统展现形式 ),我的WSL运行的是Ubuntu18.04,所以我直接在WSL上进行了实验环境的配置(即选项2)。
在配置具体的运行时环境库时,CS144已经给我们准备好了一个编写好的自动化脚本程序,可以像手册写的这样直接无脑运行这几条命令行,我配置时仔细阅读了脚本文件,在其中挑选了一些命令行来执行。
#!/bin/sh
if [ -z "$SUDO_USER" ]; then
# if the user didn't call us with sudo, re-execute
exec sudo $0 "$@"
fi
### update sources and get add-apt-repository
apt-get update
apt-get -y install software-properties-common
### add the extended source repos
add-apt-repository multiverse
add-apt-repository universe
add-apt-repository restricted
### make sure we're totally up-to-date now
apt-get update
apt-get -y dist-upgrade
### install the software we need for the VM and build env
apt-get -y install build-essential gcc gcc-8 g++ g++-8 cmake libpcap-dev htop jnettop screen \
emacs-nox vim-nox automake pkg-config libtool libtool-bin git tig links \
parallel iptables mahimahi mininet net-tools tcpdump wireshark telnet socat \
clang clang-format clang-tidy clang-tools coreutils bash doxygen graphviz \
virtualbox-guest-utils netcat-openbsd
## make a sane set of alternatives for gcc, and make gcc-8 the default
# GCC
update-alternatives --remove-all gcc &>/dev/null
for ver in 7 8; do
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-${ver} $((10 * ${ver})) \
$(for prog in g++ gcc-ar gcc-nm gcc-ranlib gcov gcov-dump gcov-tool; do
echo "--slave /usr/bin/${prog} ${prog} /usr/bin/${prog}-${ver}"
done)
done
如果你也像我一样是WSL用户,可以直接复制上面的命令行到一个文本文件中,保存为install_pkg.sh,然后执行之(这够简单了吧)。
bash ./install_pkg.sh
这几个实验是在前面所构建的实验环境的基础之上,使用telnet和netcat等工具进行一些简单的网络通信,按照实验手册操作即可。
这算是一个引入环节,来使得实验者获得一些有关网络通信的直观体验,操作过程和结果展示在此略过。
这部分主要使用Git来从CS144的官方仓库获取和编译实验所需要的前置代码:Sponge,不仅仅是这个实验,以后的数个实验都要以Sponge中的代码为开发的基石,在此基础上搭建自己的TCP/IP协议栈。
这部分同样按照手册上的指导来操作就好了。
整个CS144实验所使用的语言是C++,并且严格遵循C++ 2.0范式,这部分列举了一些有关C++语言的一些参考网站和编程注意事项。
这部分要求进入Sponge的说明文档,阅读有关起步代码(starter code)和关键类(FileDescriptor、Socket、TCPSocket、Address)的一些,这部分类在lab0中将会排上用场。
这部分将要完成webget.cc文件的编写,这个实验的目的是使用Sponge中提供的基础类完成一个简单HTTP请求的发送并将返回的请求打印出来。
要完成这个实验,首先得自己组装一个HTTP请求报文段。如何组织一个简单的HTTP报文段在2.1 Fetch a web page中已经练习过,当时在终端中输入的命令行其实就是一个简单的HTTP请求报文,只不过我们用的是telnet完成了这个过程,展示如下:
我们要做的事情也基于此展开,注意在具体实现中需要使用Sponge中已经编写好的类,以下是我的做法:
1.使用Address类首先创建一个对象,查阅文档可知Address类有多个不同构造函数,应该从中选择一个最合适的,就是下面这个:
传入标明宿主的host字符串和需要的服务即可,代码如下:
/*1.establish an address for host, service is http or port 80*/
Address HostAddress(host, "http");
2.构造一个TCPSocket对象并连接到上述地址,这部分可以参考Sponge中TCPSocket类下面的一小段示例代码,这段代码详细地演示了如何完成一个简单的TCP环回通信:
我们也只需要建立这样一个TCPSocket并连接到第1步创建的地址即可:
/*2.construct a socket for the http connection*/
TCPSocket Socket1;
Socket1.connect(HostAddress); // connect to Host Address
3.构造一个HTTP请求报文段,参考之前我们曾经在命令行中敲进去的命令。可以完成一个简单报文的构造:
/*3.package a HTTP request datagram*/
std::string HTTPRequest = "GET " + path + " HTTP/1.1\r\n" + // row 1
"Host: " + host + "\r\n" + // row 2
"Connection: close\r\n" + // row 3
"\r\n"; // empty line means the end of request
注意这里的空格和换行等细节(\r\n),这样就完成了对一个简单报文段的封装。
4.将这个封装好的请求报文发送到服务器
/*4.send HTTP request datagram, write_all is true*/
Socket1.write(HTTPRequest, true);
5.逐行读取服务器返回的响应报文并打印出来
/*5.read all the response from server*/
while(!Socket1.eof()) // continue loop until end of file(eof)
{
auto ReceivedData = Socket1.read();
std::cout << ReceivedData;
}
6.关闭TCP套接字
/*6.close the socket*/
Socket1.close();
以上代码就可以完成简单的HTTP请求和响应获取。
注意:编写完代码并保存之后,一定要重新编译这个项目,也就是在build目录下重新执行make命令,这样才会生成最新的可执行文件。
执行一下实验手册中的测试用例,发现可以成功地向目标服务器发送请求并接收到响应报文,这个返回的报文内容只有简单的Hello, CS144
最后执行一下测试用例(check_webget),可以正常通过:
这个部分是本次实验的重头戏,也就是要求实现一个基于内存的可靠字节流通信。
大概实现的系统框图如下:
要实现的类是ByteStream,内含两个部分,分别是读者和写者,读者的主要功能是向Stream中写入数据,并根据需要设置一些标志位,写者的主要功能是从Stream中读取数据并判断一些标志位。
之所以叫in-memory的可靠字节流,是因为Stream Buffer在这个实验中是驻留在内存中的一块空间,读写操作都在其中进行。
在3.2节规定编程范式时,实验手册中要求不要使用new、delete等操作符来申请或者释放内存,也不要使用malloc和free,甚至还有这样的声明:
所以如何初始化得到一个指定大小的缓冲区呢,这里我使用的就是简单的std::string,string有一个构造器是可以指定它的空间大小的。
/*stream is based on a std::string*/
std::string _Stream;
有了一个流,如何去管理它呢,这里我设置了两个指针(_WritePtr和_ReadPtr),分别是读指针和写指针分别记载即将要读出和写入的数据,并且将整个string当作一个循环队列来看待,如下所示:
那么对于一个循环队列而言,判空和判满的方式一般是多申请一个闲置位,然后按照以下的判断方式来进行(写在了注释里,这是数据结构的基本知识):
/*
2 private members, recording the position of write and read
the judgement of full and empty:
full: (_WritePtr + 1) mod (capacity + 1) == _ReadPtr
empty: _WritePtr == _ReadPtr
*/
size_t _WritePtr; // point to the byte which is to be written
size_t _ReadPtr; // point to the byte which is to be read
最后就是一些会用到的变量,比如一些标志位以及缓冲区容量、总共写入或者读出的字符数量等等:
/*private member to record the value of designated capacity*/
size_t _Capacity;
/*
2 private members recording the number of bytes which have been written or read
*/
size_t _TotalWritten;
size_t _TotalRead;
/*flags about the stream */
bool _error; //!< Flag indicating that the stream suffered an error.
bool _endin; // end of input flag
以上是为了完成本次实验而添加的一些数据成员,下面来看看具体的实现:
构造函数的实现比较简单,这里简单给出代码,注意实际申请的缓冲区空间比要求的capacity多1,多的这个字节是为了判满而预留下来的。
/*ctor for ByteStream*/
/*Attention: the Stream size is capacity+1 because a additional position is used for the judgment of full*/
ByteStream::ByteStream(const size_t capacity) : _error(false), _endin(false),
_Stream(capacity + 1, '0'),
_Capacity(capacity), _WritePtr(0), _ReadPtr(0),
_TotalWritten(0), _TotalRead(0){}
首先来看写者的API实现,这是一个API概览:
我们由简到难的展开这几个函数的具体实现:
1.首先是end_input以及set_error,我在具体实现是写者来将具体的标志位置1来实现的,所以实现非常简单,代码如下:
void ByteStream::end_input() {
_endin = true; // set the end of input flag
}
set_error在原本的代码中已经实现了,也就是将对应的_error设置为true。
2.然后是函数remain_capacity,这个函数用来计算流缓冲中剩余的空间。这里我是通过讨论的方式来解决的,代码如下:
size_t ByteStream::remaining_capacity() const {
int Diff = _ReadPtr - _WritePtr; // calculate the difference of read and write pointer
size_t Remainder = ((_WritePtr + 1) % (_Capacity + 1) == _ReadPtr) ? 0 : // 1.stream is full
(Diff == 0) ? _Capacity : // 2.stream is empty
(Diff < 0) ? Diff + _Capacity : // 3._ReadPtr is behind of _WritePtr
Diff - 1; // 4._ReadPtr is ahead of _WritePtr
return Remainder;
}
分别讨论了4种情况:
1.满
2.空
3.读指针在写指针之前(_ReadPtr < _WritePtr)
4.读指针在写指针之后(_WritePtr < _ReadPtr)
这四种情况和剩余空间之间的对应的关系通过画图可以比较快速地得到,比如以下是情况4的示意图,情况3示意图在这里不再给出。值得注意的是,因为这是一个循环队列,所以所有指针后移的动作都需要对队列的大小取模(mod (capacity + 1))。
最后就是写者的写操作,这个操作主要做了以下4件事情
1.首先使用remaining_capacity来计算剩余的空间
2.可以写入的字节数等于要写入的数据长度和剩余空间的较小者
3.写入数据到流
4.更新总写入字节数(_TotalWritten)
size_t ByteStream::write(const string &data) {
/*1.calculate the remain space of stream*/
size_t Remainder = this->remaining_capacity();
/*2.the byte can be written into stream is the smaller value of data.size() and the left space of stream*/
size_t ByteWrite = (data.size() >= Remainder) ? Remainder : data.size();
/*3.write the byte into stream one by one*/
size_t Counter = 0;
while(Counter < ByteWrite){
_Stream[_WritePtr] = data[Counter++];
_WritePtr = (_WritePtr + 1) % (_Capacity + 1); // pay attention to the %(_Capacity + 1)
// the stream is a circular queue
}
/*4.add the ByteWrite to TotalWritten*/
_TotalWritten += ByteWrite;
return ByteWrite;
}
首先给出读者API概览:
读者的主要功能是从字节流中读取数据并检查标志位,首先从最简单的检查标志位开始,这部分有以下几个函数:
1.input_ended
2.error
3.eof
4.buffer_empty
我们逐个来说,首先input_ended和error的检测都很简单,只需要查看对应的标志位是否被置为true即可,所以实现代码如下:
bool ByteStream::input_ended() const {
return _endin == true; // judge the _endin flag
}
error函数在此不再赘述,因为在原本的代码中已经实现。
流缓冲为空的标志是读写指针重合,所以buffer_empty的实现如下:
bool ByteStream::buffer_empty() const {
return _ReadPtr == _WritePtr;
}
而对应的eof函数实现如下,判断依据我认为是当前流缓冲为空并且输入已经结束:
bool ByteStream::eof() const {
return input_ended() && buffer_empty(); // if the input has ended and the buffer has nothing to be read
// that is the end of file
}
随后有两个返回总写入和总读出字符的函数(bytes_written(), bytes_read()),这两个函数的实现就是将对应的变量直接返回就行了,也没有特别的难度:
size_t ByteStream::bytes_written() const {
return _TotalWritten;
}
size_t ByteStream::bytes_read() const {
return _TotalRead;
}
下面来看函数buffer_size, 这个函数的目的是返回流中未读取的字节数量,和前面的remaining_capacity一样,这里你可以讨论来获得流中并未读取的字符数,当然从代码复用的角度来说不需要这样,只需要使用(_capacity - remaining_capcity)即可。
size_t ByteStream::buffer_size() const {
// the free space is _capacity - remaining_capacity()
return _Capacity - this->remaining_capacity();
}
最后是三个重要的函数:peek_output, pop_output, read,分别对应于探查数据、弹出数据、和读取数据。
探查数据的功能是查看将要读取的数据内容,但是并不改变读指针的位置:
1.首先我们看看还有多少个字节的数据没有被读取
2.应该读取的字节数量应该是len和没有读取的字节数量中的较小者
3.读取这些字节到PeekStr,但是并不改动读指针的位置(为此专门引入了变量PeekPtr)
注意指针后移过程中的取模运算。
string ByteStream::peek_output(const size_t len) const {
string PeekStr = ""; // result
/*1.get how many bytes has not been read*/
size_t Remainder = this->buffer_size();
/*2.the number of byte can be peeked is the smaller value of Remainder and len*/
size_t BytePeek = len >= Remainder ? Remainder : len;
/*3.copy Byte to PeekStr while _ReadPtr do not move*/
size_t PeekPtr = _ReadPtr;
size_t Counter = 0;
while(Counter++ < BytePeek)
PeekStr.push_back(_Stream[(PeekPtr++) % (_Capacity + 1)]); // mod (_Capicity + 1)
return PeekStr;
}
弹出数据就是将数据从流中删除,这是通过修改读指针来实现的,同样也会经历类似于探查数据的前三个步骤,在第三步中会将读指针后移,最终还要添加一步:把弹出的数据总量添加到_TotalRead中加以统计总读出字节数(更新_TotalRead),代码实现如下:
void ByteStream::pop_output(const size_t len) {
/*1.get how many bytes has not been read*/
size_t Remainder = this->buffer_size();
/*2.the number of byte can be popped is the smaller value of Remainder and len*/
size_t ByteRemoved = len >= Remainder ? Remainder : len;
/*3.modify the _ReadPtr to the appropriate position*/
size_t Counter = 0;
while(Counter++ < ByteRemoved)
_ReadPtr = (_ReadPtr + 1) % (_Capacity + 1); // mod (_Capacity + 1)
/*4.add the ByteRemoved to _TotalRead*/
_TotalRead += ByteRemoved;
}
最后读取数据其实就是探查数据和弹出数据的结合,这个实现起来很简单:
std::string ByteStream::read(const size_t len) {
/*combination of peek and pop*/
/*1.peek*/
string PeekStr = peek_output(len);
/*2.pop out the bytes from stream*/
pop_output(len);
return PeekStr;
}
至此,读者的API也已经全部实现完毕,保存全部代码。
请注意要用make指令重新编译代码:
随后使用make check_lab0来对实现的正确性进行检验:
通过了全部测试用例…
这就是CS144 lab0的基本完成过程,我的实现只代表我当时的思路,不是最优实现甚至不是完全正确(是的,通过所有test也不代表代码完全完备)。另外值得注意的是,这里应该有更好的实现思路,因为在测试时第6/9个测试点用时超过了1秒(这个测试好像是抓一个网页,所以慢可能和网络情况有关),尽管不同性能的机器会得到不同的结果,但是这也说明了我的代码效率偏低,可能还需要进一步的优化和更改。