# 前言
关于为什么选修这门课,为什么突然弃坑又为什么突然醒悟重新写这部分内容,请看 关于上个版本的CS144 中的几篇文章
# Envrionment Setting Up
关于我的环境,如下所示:
请注意文章创建的时间与工具版本之间的关系
- OS: Linux x86_64 (6.1.53-1-MANJARO)
- CPU: 13th Gen Intel i5-13500H (16) @ 4.700GHz
- GPU: Intel Raptor Lake-P [Iris Xe Graphics]
- Memory: 16GB
- Shell: zsh 5.9
- Editor: VS Code
- C Compiler: gcc(version 13.2.1) clang(version 16.0.6)
- C++ Compiler: g++(version 13.2.1) clang++(version 16.0.6)
- C/C++ build tools: cmake(version 3.27.6) xmake(version 2.8.3) make(version 4.4.1)
- Rust: rustc 1.73.0-nightly
- Python: Python 3.11.5
- Java: openjdk 21 2023-09-19
- Go: version go1.21.1 linux/amd64
不错,现在我可以用之前用不了的方案二了 😂
由于 sp23
使用的是 C++20
,所以保证你的 cmake
与 g++/clang++
支持 C++20
(建议直接和我用一个版本的)
拉取 github
上的代码,建议根据 How to make a fork of repo private 进行设置,然后拉取到本地:
git clone git@github.com:<username>/<repo-name>.git
# How to debug
如何使用 VS Code
进行调试?
答案当然是自己写 launch.json
了,但由于本人水平不够,所以为每个过不去的测试都会写一个简单的配置,例如 check_webget
:
{
"version": "0.2.0",
"configurations": [
{
"type": "cppdbg",
"request": "launch",
"name": "webget debug",
"program": "${workspaceFolder}/build/apps/webget",
"stopAtEntry": false,
"cwd": "${workspaceFolder}/build",
"args": [
"cs144.keithw.org",
"/nph-hasher/xyzzy"
],
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"MIMode": "gdb",
},
]
}
需要注意的点实际上就是 program
部分要写对,每个测试对应的可执行文件的名称叫什么能知道就行。如果需要多配置,在 configurations
中添加类似的配置即可。
你需要知道的一些预定义变量
- ${workspaceFolder} - 当前工作目录(根目录)
- ${workspaceFolderBasename} - 当前文件的父目录
- ${file} - 当前打开的文件名(完整路径)
- ${relativeFile} - 当前根目录到当前打开文件的相对路径(包括文件名)
- ${relativeFileDirname} - 当前根目录到当前打开文件的相对路径(不包括文件名)
- ${fileBasename} - 当前打开的文件名(包括扩展名)
- ${fileBasenameNoExtension} - 当前打开的文件名(不包括扩展名)
- ${fileDirname} - 当前打开文件的目录
- ${fileExtname} - 当前打开文件的扩展名
- ${cwd} - 启动时task工作的目录
- ${lineNumber} - 当前激活文件所选行
- ${selectedText} - 当前激活文件中所选择的文本
- ${execPath} - vscode执行文件所在的目录
# Lab0
实践部分跳过,从写代码的阶段开始,请遵守代码规范:
请使用 Vscode 中的
cmake/cmake tools
插件,可以省去很多麻烦,当然你想输命令来做也是可以的还有
clangd
来进行文件的索引(虽然感觉有些时候clangd
挺笨的)
怎么大括号要换行啊,忍不了了,直接改 .clang-format
:
---
Language: Cpp
BasedOnStyle: Mozilla
IndentWidth: 4
AccessModifierOffset: -4
ColumnLimit: 116
SpacesInParentheses: true
AlwaysBreakAfterReturnType: None
AlwaysBreakAfterDefinitionReturnType: None
SpaceBeforeCpp11BracedList: true
BreakBeforeBinaryOperators: All
Cpp11BracedListStyle: true
AllowShortBlocksOnASingleLine: Always
BreakBeforeBraces: Custom
BraceWrapping:
AfterClass: false
AfterControlStatement: false
AfterFunction: false
AfterStruct: false
AfterEnum: false
SplitEmptyFunction: false
SplitEmptyRecord: false
SplitEmptyNamespace: false
PackConstructorInitializers: NextLine
...
# Read Code
Please read over the public interfaces (the part that comes after “public:” in the files util/socket.hh and util/file_descriptor.hh. (Please note that a Socket is a type of FileDescriptor, and a TCPSocket is a type of Socket.)
这里我们需要阅读 libsponge/uitl/socket.hh
libsponge/uitl/address.hh
中对于 TCPSocket
,Socket
与 Address
的定义及用法。
实际上的要求并不复杂,我们需要建立一个 TCP
连接,然后发送一段预设好的消息,等到回复后,将其打印出来。
==仔细阅读== 文档即可完成,代码如下:
void get_URL( const string& host, const string& path ) {
TCPSocket socket = TCPSocket();
socket.connect( Address( host, "http" ) );
socket.write( "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nConnection: close\r\n\r\n" );
socket.shutdown( SHUT_WR );
string buffer;
while ( !socket.eof() ) {
socket.read( buffer );
cout << buffer;
}
}
注意这里的 read
函数和之前不一样了,需要传入一个引用,而不是接收一个返回值。
如果你使用的编译器为
clang
,注意在src/byte_stream.hh
中需要添加头文件#include <cstdint>
否则会导致uint64_t
报错。
随后,进行检查,我们可以使用两种方法
如果你是命令行偏爱者,请输入:
cd build
make -j`nproc`
./apps/webget cs144.keithw.org /hello
进行第一步测试,如果通过了,那么可以直接跑写好的单元测试:
# if you are in the `build` directory
cd ..
cmake --build build --target check_webget
然后可以看见如下:
如果你不想敲命令行,那么可以使用之前说的 cmake
插件,如下图所示:
如果嫌弃 vsc 的日志输出没有高亮的话,可以下载一个插件
Build Output Colorizer
虽然这个好像也没什么太大的用处……
这个插件的测试不如命令行稳定,可能第一次测失败了,第二次就成功了(如果失败了可以转用命令行试试看)
# An in-memory reliable byte stream
这里需要理解文档中所提到的 best effort
数据流,通俗来说,就是它只尽力传输,但不保证传输的正确性,你给他多少他就传多少。
通过文档中的介绍,我们很轻易就能发现 ByteStream
实际上就是一个循环队列,写的一方往队列里加东西,读的一方从队列里取东西,写者方到达容器极限但仍存在未写入的流时,写者会选择抛弃超出的部分。
我们需要完成的接口在文档中已列出,请 ==仔细阅读== 每个函数后的注释,保证已经明确了这个函数的功能。
注意,这里 Writer
与 Reader
都继承自 ByteStream
,所以我们只需要向 ByteStream
添加成员即可。
我们可以为类添加新的 protected
成员以实现接口。
显然,我们需要添加容器,我们可以选择 STL
中提供的 deque
,但我选择自己从头构建一个循环队列(就当复习数据结构了),我们通过一个数组和两个指针来模拟循环队列:
注意,
CS144
的标准不能使用C
中的数组,只能使用STL
封装的vector
# Init Version
我们从成员变量开始,由于我们考虑使用 vector
和双指针来模拟循环队列,并且考虑到接口中存在:
set_error() / has_error()
is_closed() / is_finished()
bytes_pushed() / bytes_poped()
所以我们可以设计一些变量,在传输的时候就进行维护,这样就可以直接返回而不需要重新计算了
class ByteStream {
protected:
uint64_t capacity_;
uint64_t write_index_ { 0 };
uint64_t read_index_ { 0 };
uint64_t read_count_ { 0 };
uint64_t write_count_ { 0 };
bool closed_ { false };
bool error_ { false };
std::vector<char> buffer_;
// ... other code
}
我们从上面提到的简单接口开始实现,首先是构造函数:
ByteStream::ByteStream( uint64_t capacity ) : capacity_( capacity ){
buffer_.resize( capacity );
}
这里使用了
resize
而非reserve
,是因为reserve
只会设置capacity
,当你第一次放元素进去的时候还是需要push_back / emplace_back
,并不适合我们这里的双指针。至于用
vector
的原因,就是我们并不知道std::array<typename Tp, size_t Nm>
中的Nm
值,其为RunTime
的。
简单的接口如下:
void Writer::close() {
// Your code here.
closed_ = true;
}
void Writer::set_error() {
// Your code here.
error_ = true;
}
bool Writer::is_closed() const {
// Your code here.
return closed_;
}
uint64_t Writer::bytes_pushed() const {
// Your code here.
return write_count_;
}
bool Reader::is_finished() const {
// Your code here.
return closed_ && bytes_buffered() == 0;
}
bool Reader::has_error() const {
// Your code here.
return error_;
}
uint64_t Reader::bytes_popped() const {
// Your code here.
return read_count_;
}
在这里,请注意 is_finished
的实现,如何保证已经传输完成?显然,我们需要保证两点:
- 输入端保证不会有输入了
- 缓冲区已经被清空了
而对于 byte_buffered
,我们注意到还有一个类似的函数 available_capacity
,但请注意,这两个函数不能调用(因为属于不同的类)
但其实我们可以发现 available_capacity() = capacity_ - byte_buffered()
所以我们先来实现 byte_buffered()
,实际上就是在求循环队列的长度:(队首 - 队尾 + 容量)% 容量
然而在这里我们需要特判一种情况:
由于队首和队尾相同的情况不一定是队列为空,也有可能是队列长度恰好等于总容量。
于是,若队列非空(也就是 buffer
非空)而计算的 result
却为 0,那么显然这时的队列并不是初始状态(完全空),相反的,整个缓冲区应该都是满的。
uint64_t Reader::bytes_buffered() const {
// Your code here.
auto result = ( write_index_ - read_index_ + capacity_ ) % capacity_;
if ( !result && write_count_ > read_count_ ) {
result = capacity_;
}
return result;
}
这样,我们显然也能实现 available_capacity
:
uint64_t Writer::available_capacity() const {
// Your code here.
uint64_t busy = ( write_index_ - read_index_ + capacity_ ) % capacity_;
if ( write_count_ != read_count_ && !busy ) {
busy = capacity_;
}
return capacity_ - busy;
}
当 helper
函数都实现完后,接着就可以实现主要函数了,我们从较为简单的 Writer.push()
看起,由于是尽力传输,所以 push
会按照下面步骤来完成:
- 判断是否已经关闭输入端,如果是,那么不传输,否则继续
- 计算出当前缓冲区的剩余容量,如果需要传输的数据长度大于这个容量,那么我们只传输长度为剩余容量的子串,否则我们传输整个字符串
- 维护
write_count_
的值
代码就十分显然了:
void Writer::push( string data ) {
// Your code here.
if ( closed_ ) {
return;
}
auto remaining = available_capacity();
if ( remaining >= data.size() ) {
for ( auto& i : data ) {
buffer_[write_index_] = i;
write_index_ = ( write_index_ + 1 ) % capacity_;
}
write_count_ += data.size();
} else if ( remaining > 0 ) {
data = data.substr( 0, remaining );
for ( auto& i : data ) {
buffer_[write_index_] = i;
write_index_ = ( write_index_ + 1 ) % capacity_;
}
write_count_ += remaining;
}
}
当然这里会有一个
bug
,在后面会修改
对于 Reader
而言,我们有两个函数, peek
与 pop
,其中 peek
只是读出内容但并不会清理缓冲区,也不会对其他变量做任何操作,可以想象成 消费者只是先看一看,但并没有真的付钱把商品拿走 这种情况。
但不同的是,你会发现 peek
居然没有输入参数,这说明每次 peek
,Reader
都会把缓冲区内所有可读的东西扔出去(最好情况),那么你就会写出如下代码:
string_view Reader::peek() const {
// Your code here.
auto reamining = byte_buffered();
vector<char> tmp;
uint64 count = 0, index = read_index;
while(count < remaining){
tmp.emplace_back(buffer_[index]);
index = ( index + 1 ) % capacity_;
}
auto result = string_view(tmp.begin(), tmp.end());
return result;
}
乍一看好像没什么问题,但注意
string_view
是无内存的,他只是引用了一个已经存在的内存地址而已,你可以把它当作一个切片。 而在上面那个函数中,result
引用了tmp
的内存地址,但tmp
是个局部变量,当函数返回时就会被销毁,那么返回回去的result
就成为了一个dangling reference
,我们永远无法取得存在于buffer_
的数据。
明白了这一点,实际上就好做了很多,我们可以让 string_view
直接引用 buffer_
,但请注意,这样的话我们就损失了一些吞吐率(没办法一次全取出来了,因为队列是循环的,会导致尾在头前面的情况),代码如下:
string_view Reader::peek() const {
// Your code here.
auto result = string_view( buffer_.begin(), buffer_.end() );
if ( write_index_ > read_index_ ) {
result = result.substr( read_index_, write_index_ - read_index_ );
} else {
result = result.substr( read_index_ );
}
return result;
}
至于 pop
,就很显然了,并不需要太多的思考:
void Reader::pop( uint64_t len ) {
// Your code here.
read_index_ = ( read_index_ + len ) % capacity_;
read_count_ += len;
}
测试如下:
其错误为:
# Final Version
出现这个错误的原因是:
我们上面的 push
和 peek
都没有处理传输的字符串其实是个空串的情况(其实主要是 push
,peek
的 substr
应该都已经算是处理了)
于是我们修改 push
如下:
void Writer::push( string data ) {
// Your code here.
if ( closed_ ) {
return;
}
if ( data.empty() ) {
return;
}
uint64_t remaining = available_capacity();
if ( remaining >= data.size() ) {
for ( auto& i : data ) {
buffer_[write_index_] = i;
write_index_ = ( write_index_ + 1 ) % capacity_;
}
write_count_ += data.size();
} else if ( remaining > 0 ) {
data = data.substr( 0, remaining );
for ( auto& i : data ) {
buffer_[write_index_] = i;
write_index_ = ( write_index_ + 1 ) % capacity_;
}
write_count_ += remaining;
}
}
对于 Reader 而言:
string_view Reader::peek() const {
// Your code here.
if ( bytes_buffered() == 0 ) {
return {};
}
auto result = string_view( buffer_.begin(), buffer_.end() );
if ( write_index_ > read_index_ ) {
result = result.substr( read_index_, write_index_ - read_index_ );
} else {
result = result.substr( read_index_ );
}
return result;
}
这样,就能通过所有测试点了
如果你更喜欢命令行,那么可以测试如下: