-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.json
1 lines (1 loc) · 337 KB
/
index.json
1
[{"content":"在C++中,编译器会根据类的定义情况自动决定是否生成默认的特殊成员函数(如构造函数、拷贝/移动操作、析构函数)。\n1. 用户显式声明相关成员函数 显式声明或删除某个函数:\n如果用户显式声明(即使使用 =default 或 =delete)某个特殊成员函数,编译器将不再生成默认版本。例如:\nclass Example { public: Example() = default; // 允许生成默认构造函数 Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 编译器不再生成默认的移动构造函数和移动赋值运算符 }; 2. 用户定义析构函数、拷贝/移动操作的影响 定义析构函数:\n如果用户定义了析构函数(即使为空),编译器会删除默认的移动操作(移动构造函数和移动赋值运算符),但拷贝操作仍可能生成(除非其他条件阻止)。\nclass Example { public: ~Example() {} // 用户定义的析构函数 // 移动操作被隐式删除,拷贝操作可能生成(若无其他限制) }; 定义拷贝操作:\n如果用户定义了拷贝构造函数或拷贝赋值运算符,编译器会删除默认的移动操作。\nclass Example { public: Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 移动操作被隐式删除 }; 定义移动操作:\n如果用户定义了移动构造函数或移动赋值运算符,编译器会删除默认的拷贝操作。\n#include \u0026lt;utility\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;iostream\u0026gt; struct Example { std::string str; Example() = default; Example(const std::string\u0026amp; s): str(s) { std::cout \u0026lt;\u0026lt; \u0026#34;Example()\\n\u0026#34;; } Example(Example\u0026amp;\u0026amp; other) { str = std::move(other.str); std::cout \u0026lt;\u0026lt; \u0026#34;Example(Example\u0026amp;\u0026amp;)\\n\u0026#34;; } Example\u0026amp; operator=(Example\u0026amp;\u0026amp; other) { str = std::move(other.str); std::cout \u0026lt;\u0026lt; \u0026#34;operator=(Example\u0026amp;\u0026amp;)\\n\u0026#34;; return *this; } }; int main() { Example x1(\u0026#34;Hello\u0026#34;); Example x2 = std::move(x1); Example x3; x3 = std::move(x2); } 执行结果为:\nExample() Example(Example\u0026amp;\u0026amp;) operator=(Example\u0026amp;\u0026amp;) 3. 类成员或基类的限制 不可默认构造/拷贝/移动的成员:\n如果类中包含无法默认构造、拷贝或移动的成员(如 std::unique_ptr、带有删除拷贝操作的类),则对应的默认特殊成员函数会被隐式删除。\nclass Example { std::unique_ptr\u0026lt;int\u0026gt; ptr; // 不可拷贝 }; // 默认的拷贝构造函数和拷贝赋值运算符被删除 基类或成员的特殊成员函数被删除:\n如果基类或成员的某个特殊成员函数被删除或不可访问,派生类对应的函数也会被隐式删除。\nclass NonCopyable { public: NonCopyable(const NonCopyable\u0026amp;) = delete; }; class Derived : public NonCopyable { // 拷贝构造函数被隐式删除,因为基类的拷贝构造函数被删除 }; ","permalink":"https://kerolt.github.io/posts/c++/c++%E4%BD%95%E6%97%B6%E4%BC%9A%E9%98%BB%E6%AD%A2%E9%BB%98%E8%AE%A4%E7%9A%84%E7%89%B9%E6%AE%8A%E6%88%90%E5%91%98%E5%87%BD%E6%95%B0%E7%9A%84%E7%94%9F%E6%88%90/","summary":"在C++中,编译器会根据类的定义情况自动决定是否生成默认的特殊成员函数(如构造函数、拷贝/移动操作、析构函数)。\n1. 用户显式声明相关成员函数 显式声明或删除某个函数:\n如果用户显式声明(即使使用 =default 或 =delete)某个特殊成员函数,编译器将不再生成默认版本。例如:\nclass Example { public: Example() = default; // 允许生成默认构造函数 Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 编译器不再生成默认的移动构造函数和移动赋值运算符 }; 2. 用户定义析构函数、拷贝/移动操作的影响 定义析构函数:\n如果用户定义了析构函数(即使为空),编译器会删除默认的移动操作(移动构造函数和移动赋值运算符),但拷贝操作仍可能生成(除非其他条件阻止)。\nclass Example { public: ~Example() {} // 用户定义的析构函数 // 移动操作被隐式删除,拷贝操作可能生成(若无其他限制) }; 定义拷贝操作:\n如果用户定义了拷贝构造函数或拷贝赋值运算符,编译器会删除默认的移动操作。\nclass Example { public: Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 移动操作被隐式删除 }; 定义移动操作:\n如果用户定义了移动构造函数或移动赋值运算符,编译器会删除默认的拷贝操作。\n#include \u0026lt;utility\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;iostream\u0026gt; struct Example { std::string str; Example() = default; Example(const std::string\u0026amp; s): str(s) { std::cout \u0026lt;\u0026lt; \u0026#34;Example()\\n\u0026#34;; } Example(Example\u0026amp;\u0026amp; other) { str = std::move(other.","title":"C++何时会阻止默认的特殊成员函数的生成"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\n这个project和之前就不一样了,开始深入数据库内核的实现了。需要理清楚一条sql语句是如何被执行的,方才能写出代码。\n前置奶酪 一条SQL语句的执行 这里需要去看看一条sql语句传入bustub内部之后的代码:src/common/bustub_instance.cpp:ExecuteSqlTxn:\nauto BustubInstance::ExecuteSqlTxn(const std::string \u0026amp;sql, ResultWriter \u0026amp;writer, Transaction *txn, std::shared_ptr\u0026lt;CheckOptions\u0026gt; check_options) -\u0026gt; bool { if (!sql.empty() \u0026amp;\u0026amp; sql[0] == \u0026#39;\\\\\u0026#39;) { // 处理元命令 ... } // binder,但是在其中会使用libpg_query来解析sql语句 bustub::Binder binder(*catalog_); binder.ParseAndSave(sql); // 经过上一步后,binder中的statement_nodes_存储着所有的语句解析节点 for (auto *stmt : binder.statement_nodes_) { // 将stmt转换成BoundStatement对象,方便后面处理数据 auto statement = binder.BindStatement(stmt); // 只有不需要构建plan树、不需要进行优化的sql语句才会在switch之后继续执行 switch (statement-\u0026gt;type_) { ... } // 生成初步的执行计划 bustub::Planner planner(*catalog_); planner.PlanQuery(*statement); // 优化刚刚的执行计划 bustub::Optimizer optimizer(*catalog_, IsForceStarterRule()); auto optimized_plan = optimizer.Optimize(planner.plan_); ... // 执行优化后的plan,这里会使用火山模型去根据下面节点的Next函数来执行相应的算子 execution_engine_-\u0026gt;Execute(optimized_plan, \u0026amp;result_set, txn, exec_ctx.get()); // 将执行结果输出至指定位置 ... } return 是否执行成功;\t} 在binder之后,我们就有了一条sql的语句解析节点,例如执行select * from (select * from test_2 where colA \u0026gt; 10) where colB \u0026gt; 2;,其statement node如下:\nBoundSelect { table=BoundSubqueryRef { alias=__subquery#0, subquery=BoundSelect { table=BoundBaseTableRef { table=test_2, oid=23 }, columns=[\u0026#34;test_2.colA\u0026#34;, \u0026#34;test_2.colB\u0026#34;, \u0026#34;test_2.colC\u0026#34;], groupBy=[], having=, where=(test_2.colA\u0026gt;10), limit=, offset=, order_by=[], is_distinct=false, ctes=, }, columns=[\u0026#34;test_2.colA\u0026#34;, \u0026#34;test_2.colB\u0026#34;, \u0026#34;test_2.colC\u0026#34;], }, columns=[\u0026#34;__subquery#0.test_2.colA\u0026#34;, \u0026#34;__subquery#0.test_2.colB\u0026#34;, \u0026#34;__subquery#0.test_2.colC\u0026#34;], groupBy=[], having=, where=(__subquery#0.test_2.colB\u0026gt;2), limit=, offset=, order_by=[], is_distinct=false, ctes=, } 其中的子查询和where的条件还有需要哪些列都能非常清楚的看到。\nIterator Model 通常一个 SQL 会被组织成树状的查询计划,数据从叶子节点流到根节点,查询结果在根节点中得出。\nbustub中采用的数据库查询执行模型叫做迭代器模型,也叫火山模型。\n查询计划(query plan)中的每步operator对应的executor都实现一个next函数,每次调用时,operator返回一个tuple或者 null,后者表示数据已经遍历完毕。operator 本身实现一个循环,每次调用其 child operators 的 next 函数,从它们那边获取下一条数据供自己操作,这样整个 query plan 就被从上至下地串联起来。\n但是像Joins, Aggregates, Subqueries, Order By这样的操作需要等所有children返回它们的tuple。虽然一次调用请求一条数据,占用内存较小,但函数调用开销大。\nCatalog, Table and Index 下图出处:https://www.cnblogs.com/joey-wang/p/17351258.html\n索引index 在Bustub中,索引用于加速数据访问。索引通过维护表中数据的有序结构,使得查询可以更快地定位到所需的记录。\n结构 索引的结构图和上面表的结构图类似。在catalog中,可以获取到一个表对应的所有IndexInfo,每个IndexInfo中包含着这个索引的信息,这里讲两个个我认为比较重要的成员变量:\nkey_schema_:索引对应的列的结构,例如使用其ToString()函数时,其会返回(添加了索引的列的名称:该列的数据类型) index_:这是一个指针,指向一个Index类的对象,也就是真正的索引 // catalog.h class Catalog { public: template \u0026lt;class KeyType, class ValueType, class KeyComparator\u0026gt; auto CreateIndex(Transaction *txn, const std::string \u0026amp;index_name, const std::string \u0026amp;table_name, const Schema \u0026amp;schema, const Schema \u0026amp;key_schema, const std::vector\u0026lt;uint32_t\u0026gt; \u0026amp;key_attrs, std::size_t keysize, HashFunction\u0026lt;KeyType\u0026gt; hash_function, bool is_primary_key = false, IndexType index_type = IndexType::HashTableIndex) -\u0026gt; IndexInfo *; auto GetIndex(const std::string \u0026amp;index_name, const std::string \u0026amp;table_name) -\u0026gt; IndexInfo *; auto GetIndex(const std::string \u0026amp;index_name, const table_oid_t table_oid) -\u0026gt; IndexInfo *; auto GetIndex(index_oid_t index_oid) -\u0026gt; IndexInfo *; auto GetTableIndexes(const std::string \u0026amp;table_name) const -\u0026gt; std::vector\u0026lt;IndexInfo *\u0026gt;; ... private: ... /** * Map index identifier -\u0026gt; index metadata. * * NOTE: that `indexes_` owns all index metadata. */ std::unordered_map\u0026lt;index_oid_t, std::unique_ptr\u0026lt;IndexInfo\u0026gt;\u0026gt; indexes_; /** Map table name -\u0026gt; index names -\u0026gt; index identifiers. */ std::unordered_map\u0026lt;std::string, std::unordered_map\u0026lt;std::string, index_oid_t\u0026gt;\u0026gt; index_names_; /** The next index identifier to be used. */ std::atomic\u0026lt;index_oid_t\u0026gt; next_index_oid_{0}; }; struct IndexInfo { ... /** The schema for the index key */ Schema key_schema_; /** The name of the index */ std::string name_; /** An owning pointer to the index */ std::unique_ptr\u0026lt;Index\u0026gt; index_; /** The unique OID for the index */ index_oid_t index_oid_; /** The name of the table on which the index is created */ std::string table_name_; /** The size of the index key, in bytes */ const size_t key_size_; /** Is primary key index? */ bool is_primary_key_; /** The index type */ [[maybe_unused]] IndexType index_type_{IndexType::BPlusTreeIndex}; }; Index中有着三个虚函数供其派生类去实现,其唯一的成员变量的类型为IndexMeta,用来存储一些元信息,例如这个索引的名称,它所属的表的名称,最重要的还有一个key_attrs_,稍后就说谈论它。\n// index.h class IndexMeta { ... private: /** The name of the index */ std::string name_; /** The name of the table on which the index is created */ std::string table_name_; /** The mapping relation between key schema and tuple schema */ const std::vector\u0026lt;uint32_t\u0026gt; key_attrs_; /** The schema of the indexed key */ std::shared_ptr\u0026lt;Schema\u0026gt; key_schema_; /** Is primary key? */ bool is_primary_key_; }; class Index { ... private: /** The Index structure owns its metadata */ std::unique_ptr\u0026lt;IndexMetadata\u0026gt; metadata_; }; fall2023我们使用的是哈希索引,底层使用的就是在project2中实现的可拓展哈希。\n// extendible_hash_table_index.h #define HASH_TABLE_INDEX_TYPE ExtendibleHashTableIndex\u0026lt;KeyType, ValueType, KeyComparator\u0026gt; template \u0026lt;typename KeyType, typename ValueType, typename KeyComparator\u0026gt; class ExtendibleHashTableIndex : public Index { public: ExtendibleHashTableIndex(std::unique_ptr\u0026lt;IndexMetadata\u0026gt; \u0026amp;\u0026amp;metadata, BufferPoolManager *buffer_pool_manager, const HashFunction\u0026lt;KeyType\u0026gt; \u0026amp;hash_fn); ~ExtendibleHashTableIndex() override = default; auto InsertEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction) -\u0026gt; bool override; void DeleteEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction) override; void ScanKey(const Tuple \u0026amp;key, std::vector\u0026lt;RID\u0026gt; *result, Transaction *transaction) override; protected: // comparator for key KeyComparator comparator_; // container DiskExtendibleHashTable\u0026lt;KeyType, ValueType, KeyComparator\u0026gt; container_; }; 更新索引 当插入新记录时,不仅需要将记录插入到表中,还需要将相应的索引条目插入到索引中。这样,后续的查询操作可以利用索引快速定位到目标记录。如果不更新索引,后续的查询操作可能会错过新插入的记录,导致查询结果不准确。\nbustub中有哈希索引和B+Tree索引,fall2023版本使用的是可拓展哈希作为作为索引。不过这两个具体的实现都有一个基类Index,其中有以下虚函数需要子类去实现:\nInsertEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction): 插入一个索引条目。 DeleteEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction): 删除一个索引条目。 ScanKey(const Tuple \u0026amp;key, std::vector\u0026lt;RID\u0026gt; *result, Transaction *transaction): 根据索引键搜索记录,并将结果RID存储在指定的向量中。 所以不管用的是哈希还是B+Tree,在操作索引时用的接口都相同。\n如何理解索引 就如网上很多介绍索引的博客所描述的那样,数据库索引是用来加速检索速度的,就如同新华字典中的音节索引一样:\n如同table_info,catalog中也有许多的index_info,每个index_info就如同上图音节表中的一个字母。我们对一个字段(列)构建一个索引,就如同在上图中音节表中多加一个字母(例如X)。\n需要插入一条记录时,就往对应的索引下插入(记录, 对应记录的地址)这样的键值对,例如上图的(xian, 519),这里的地址为RID。 需要删除一条记录时,在对应的索引下删掉匹配的键值对。 需要更新一条记录时,由于bustub没有提供更新索引的API,所以可以用先删除再插入的方式模拟更新。 执行器如何使用索引获取数据 当执行器需要从表中获取数据时,如果查询计划中包含索引扫描操作,执行器会通过索引来快速定位数据。以下是具体的步骤:\n解析查询计划: 执行器根据查询计划确定需要使用的索引。 获取索引的元数据,包括索引键的模式和表列的映射关系。 构建索引键: 根据查询条件和索引的元数据,构建索引键。这通常涉及到从查询条件中提取列值,并根据索引键的模式进行转换。 使用索引进行搜索: 调用索引的 ScanKey 方法,传入构建好的索引键和一个结果RID向量。 索引会根据键值查找对应的记录,并将找到的RID存储在结果向量中。 读取数据页: 使用结果向量中的RID,从缓冲池中查找对应的页。如果页不在缓冲池中,则从磁盘加载到缓冲池。 从页中读取数据并创建 Tuple 对象。 处理和返回结果: 使用 Tuple 对象的方法(如 GetValue、IsNull 等)访问和处理元组中的数据。 将处理后的数据作为结果返回给用户或进一步处理。 谓词下推 谓词下推(Predicate Pushdown)是数据库查询优化中的一种技术,其核心思想是将查询中的过滤条件(即谓词)尽可能早地应用到查询执行计划的底部,也就是数据生成的地方。这样做的目的是为了减少数据的传输量和处理量,从而提高查询效率。\n具体来说,谓词下推包括以下几个方面:\n过滤条件前移:在查询执行的过程中,尽早地对数据进行过滤,这样不需要将所有数据都传递到上层操作中,只传递满足条件的数据。 减少数据传输:通过在数据生成的阶段就进行过滤,可以减少从数据库存储引擎到查询处理引擎之间的数据传输量。 减少CPU处理:不需要对所有数据进行后续的处理,只需要处理已经过滤的数据,这样可以减少CPU的工作量。 利用索引:如果过滤条件可以利用现有的索引,谓词下推可以使得查询直接利用索引来快速定位数据,而不是扫描整个表。 优化查询计划:数据库优化器会根据谓词下推的原则重新规划查询的执行步骤,生成更高效的查询计划。 例如:\nSELECT * FROM employees WHERE department_id = 5 AND salary \u0026gt; 50000; 在这个查询中,WHERE子句包含了两个过滤条件。如果不进行谓词下推,数据库可能会先扫描整个employees表,然后将所有行传递给上层操作,之后再应用过滤条件。而通过谓词下推,数据库可以在扫描表的时候直接应用这些过滤条件,只返回部门ID为5且薪资大于50000的员工记录。\n谓词下推是数据库查询优化中非常重要的一环,它有助于提高查询性能,特别是在处理大规模数据集时。数据库优化器会尝试自动应用谓词下推,但有时开发者也可以通过编写更优化的查询条件来帮助优化器更好地进行谓词下推。\nTask1 - Access Method Executors SeqScan 顺序扫描指定的表,表的遍历可以使用TableIterator。\n每次找到一条没有被标记为“删除”或者不是where之类的过滤子句匹配(这里会在delete操作中说明)的tuple(记录)就并返回,如果已经扫描到了表的结束位置则返回false。\nInsert 为什么Insert等Executor有child而SeqScan没有? InsertExecutor 的主要职责是将一条或多条记录插入到指定的表中。它可能需要依赖于其他 Executor 来获取要插入的数据。例如,如果 INSERT 操作是从一个 SELECT 查询的结果集中插入数据,那么 InsertExecutor 可能会有一个子 Executor(如 SeqScanExecutor 或其他类型的 Executor),该子 Executor 负责执行 SELECT 操作并提供数据给 InsertExecutor。\n因此,InsertExecutor 有 child 是因为它可能需要从另一个查询的结果中获取数据。\nSeqScanExecutor的主要职责是对表进行全表扫描,即按顺序读取表中的所有记录。这是一个基本的操作,通常不需要其他 Executor 的支持来完成其工作。\n它直接作用于存储层,遍历表中的每一行数据,因此没有子 Executor。它的任务相对简单,就是遍历和返回表中的所有记录。\n简而言之,InsertExecutor 需要 child 是因为它的操作可能涉及从其他查询结果中获取数据,而 SeqScanExecutor 不需要 child 是因为它的任务是独立完成的,只需遍历表中的所有记录即可。这反映了数据库执行计划中不同操作之间的依赖关系和交互方式。其他Executor同理。\n举个批量插入的🌰:\n假设我们有一个 orders 表,包含以下列:\norder_id (主键) customer_id product_id quantity order_date 我们希望通过一个子查询(select)来获取一批订单记录,并将这些记录插入到 orders 表中:\nINSERT INTO orders (customer_id, product_id, quantity, order_date) SELECT customer_id, product_id, quantity, order_date FROM pending_orders WHERE status = \u0026#39;approved\u0026#39;; 很明显,我们在插入之前要从select子句中获取数据,因此这个子查询操作就是insert操作的child_executor。\n没有子操作时,需要插入的数据从哪里获取? 比如执行如下SQL时:\ninsert into test_1 values (202, 1, 2, 3); 从肉眼看可以知道需要插入的数据为(202, 1, 2, 3),但是在代码中又是从哪里获取的呢?\n让我们使用一下explain工具来看看这条SQL语句在bustub内部做了什么:\nbustub\u0026gt; explain insert into test_1 values (202, 1, 2, 3); === BINDER === BoundInsert { table=BoundBaseTableRef { table=test_1, oid=22 }, select= BoundSelect { table=BoundExpressionListRef { identifier=__values#0, values=[[\u0026#34;202\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;3\u0026#34;]] }, columns=[\u0026#34;__values#0.0\u0026#34;, \u0026#34;__values#0.1\u0026#34;, \u0026#34;__values#0.2\u0026#34;, \u0026#34;__values#0.3\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } } === PLANNER === Insert { table_oid=22 } | (__bustub_internal.insert_rows:INTEGER) Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) Values { rows=1 } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) === OPTIMIZER === Insert { table_oid=22 } | (__bustub_internal.insert_rows:INTEGER) Values { rows=1 } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) 将目标聚焦在生成的查询计划上,从上到下,在一个Insert的查询计划中,使用Projection从其输入源(如表扫描、索引扫描、连接等)中提取所需的列,最下层使用Value获取到要操作的数据!\n所以对应的,InsertExecutor的child_executor为ProjectionExecutor,而ProjectionExecutor的child_executor为ValuesExecutor,使用迭代器模型就能很方便的获取到数据了(ValuesExecutor就是最后的Executor,其Next函数不会再往下调用,其所做的只是根据在解析SQL及其之后的一些步骤中得到的需要操作的数据封装成一个tuple进行返回)。\nDelete 需要写的代码和insert操作的基本相同。但有个地方需要注意一下,在执行一条delete语句时,让我们看看做了些什么:\nbustub\u0026gt; explain delete from test_1 where colA = 999; === BINDER === Delete { table=BoundBaseTableRef { table=test_1, oid=22 }, expr=(test_1.colA=999) } === PLANNER === Delete { table_oid=22 } | (__bustub_internal.delete_rows:INTEGER) Filter { predicate=(#0.0=999) } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) === OPTIMIZER === Delete { table_oid=22 } | (__bustub_internal.delete_rows:INTEGER) SeqScan { table=test_1, filter=(#0.0=999) } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) 可以看到,在optimizer阶段,where子句的filter下放至SeqScan处与其合并了,也就是说,我们需要在实现SeqScanExecutor时注意处理一下filter。这里提示一下:\nwhile (cur_tuple.first.is_deleted_ || (plan_-\u0026gt;filter_predicate_ \u0026amp;\u0026amp; !(plan_-\u0026gt;filter_predicate_-\u0026gt;Evaluate(tuple, GetOutputSchema()).GetAs\u0026lt;bool\u0026gt;()))) 如果其返回true,说明filter匹配到了数据(就如例子中匹配到了colA列为999的tuple),如果此时这个tuple没有被标记为删除,那么就说明找到了我们需要删除的tuple。\nUpdate 我们如何知道update需要更新的数据从哪里取呢?\nbustub\u0026gt; explain(p, o) update test_1 set colB = 15445; === PLANNER === Update { table_oid=22, target_exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;15445\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } Filter { predicate=true } SeqScan { table=test_1 } === OPTIMIZER === Update { table_oid=22, target_exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;15445\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } SeqScan { table=test_1, filter=true } 看看Update中,有一个target_exprs数组,这个数组可不就是我们的一行数据吗,并且是需要更新的那行数据:update语句可以不加where子句,这样就是选中表中的所有行,也就是这里将表中colB列的数据都update为15445!\n对于target_exprs这个数组,我们可以通过plan_-\u0026gt;target_expressions_获取,然后用其构建一个新的tuple。\n需要注意的是,这里并没有提供直接更新tuple的操作,所以我们的update操作可以用先删除后插入的方式来模拟。\nIndexScan 我们首先需要完成OptimizeSeqScanAsIndexScan这个优化步骤。\n假设现在我们有一个表叫“test_1”,其列如下:\n+-------------+-------------+-------------+-------------+ | test_1.colA | test_1.colB | test_1.colC | test_1.colD | +-------------+-------------+-------------+-------------+ 现在我们希望这条SQL能执行的更快:\nselect * from test_1 where colB = 11; 那比较不错的方法就是给colB列加上索引:\ncreate index v1 on test_1(colB); 这样在执行时可以更快速的查找数据。\n那么为了实现这一目标,我们需要通过OptimizeSeqScanAsIndexScan将plan树中的SeqScanPlanNode转换成IndexScanPlanNode,这样我们才能使用IndexScanPlanNode对应的算子\u0026mdash;IndexScanExecutor去使用索引。但是由于bustub的一些设计,需要遵循以下规则才可以转化:\n当前的节点的类型必须是PlanType::SeqScan 当前节点必须有filter谓词,如果只是select * from test_1;这样的是不需要使用索引的 当前表中必须有索引,没有索引还玩啥呢 fileter谓词中的逻辑表达式只能有一个,并且其类型必须是ComparisonType::Equal(我想这里必须是“等于”是不是因为fall2023使用的索引是哈希索引) 在当前表的索引信息中找到与filter谓词相对应的索引后才能返回一个IndexScanPlanNode 在select * from test_1 where colB = 11;中,加了索引后,最需要关注的就是where colB = 11这一个过滤条件。bustub中要求IndexScan过滤运算符必须是=,且只能有一个条件。如果colA和colB都是索引,然后执行select * from test_1 where colB = 11 and colA = 1;,这样是不会走索引优化的。\n我们将查询计划中的过滤谓词转化成ComparisonExpression类型,据我的理解,其可通过GetChildAt函数获取比较谓词左边的列名表达式(即这里的“colB”,有了这个列名的表达式,我们就可以获取到这个列的col_id值)和右边的值(即“11”)。之后需要去这张表中的所有索引中去找是否有colB的索引,怎么确定是否有呢,那就要看这个表中的每个Index的key_attrs_:\n在Index中,key_attrs_决定了索引的关键字由哪些列组成,对应了每个列的下标。例如:\n如果表的 schema 定义有 5 列,分别为 A, B, C, D, E。 某个索引的 key_attrs_ 是 [0, 2],则表示该索引使用了第 0 列(A)和第 2 列(C)作为其关键字。 如果之前我们获取的列的col_id值和某个Index的key_attrs_中的值相同,那么就存在相应的索引!这时构造一个IndexScanPlanNode返回即可(参考merge_filter_scan.cpp中是如何做的)。\n当优化器成功更换节点后,在执行时就会走索引,其底层算子就会使用到IndexScanExecutor。到这里,这个算子需要做的事情就很简单了,就是调用哈希索引的ScanKey进行查找,不过这里有3点需要注意:\n在project2中,我们实现的索引引擎只支持一个键对应一个值(不只是这个版本的可拓展哈希,其他版本中的B+Tree也只要求这样实现),也就是我们的这个算子在底层索引引擎不扩展的情况下最多查到一条记录,这样的话就可以在算子的Init函数中调用ScanKey查找记录就行。 可拓展哈希中是将一个Tuple的data转化一下当作key,所以在索引中,其是将索引列的值作为key,其对应的oid作为值。在使用ScanKey时,第一个参数需要的tuple将用查询计划节点IndexScanPlanNode中的pred_key_构造。 表中的tuple的元数据中,其is_deleted_可能为true,这说明这个tuple在逻辑上已经删除了,所以如果我们通过2中获取的oid对应的tuple是这种情况,就不用向上返回数据。 Task2 - Aggregation \u0026amp; Join Executors Aggregation 分析一个例子:\nbustub\u0026gt; EXPLAIN SELECT MAX(colC), MIN(colB) FROM test_1 GROUP BY colA HAVING MAX(colB) \u0026gt; 10; === BINDER === BoundSelect { table=BoundBaseTableRef { table=test_1, oid=22 }, columns=[\u0026#34;max([\\\u0026#34;test_1.colC\\\u0026#34;])\u0026#34;, \u0026#34;min([\\\u0026#34;test_1.colB\\\u0026#34;])\u0026#34;], groupBy=[\u0026#34;test_1.colA\u0026#34;], having=(max([\u0026#34;test_1.colB\u0026#34;])\u0026gt;10), where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (\u0026lt;unnamed\u0026gt;:INTEGER, \u0026lt;unnamed\u0026gt;:INTEGER) Filter { predicate=(#0.1\u0026gt;10) } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) Agg { types=[\u0026#34;max\u0026#34;, \u0026#34;max\u0026#34;, \u0026#34;min\u0026#34;], aggregates=[\u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.1\u0026#34;], group_by=[\u0026#34;#0.0\u0026#34;] } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) === OPTIMIZER === Projection { exprs=[\u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (\u0026lt;unnamed\u0026gt;:INTEGER, \u0026lt;unnamed\u0026gt;:INTEGER) Filter { predicate=(#0.1\u0026gt;10) } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) Agg { types=[\u0026#34;max\u0026#34;, \u0026#34;max\u0026#34;, \u0026#34;min\u0026#34;], aggregates=[\u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.1\u0026#34;], group_by=[\u0026#34;#0.0\u0026#34;] } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) 对于AggregationExecutor,我们可以获取到SQL语句中:\n聚合操作的types(进行聚合操作的类型)和aggregates(需要聚合操作的列),二者一一对应 需要group by进行分组的列 再看看lecture中的这个例子,其对列cid使用group by进行分组,其中涉及的聚合操作为AVG,可转换成COUNT和SUM操作。这里相当于:\ntypes = [\u0026#39;count\u0026#39;, \u0026#39;sum\u0026#39;] aggregates = [\u0026#39;s.gpa\u0026#39;, \u0026#39;s.gpa\u0026#39;] 我的理解是根据group by的字段的值进行hash函数处理作为哈希表的键,例如图中的“15-445”,“15-826”等;然后哈希表的值为一个集合,这个集合的大小和types和aggregates的大小相同,并且对应的位置就为aggregates的值:例如图中键“15-445”的值中,第一个元素就为COUNT操作下s.gpa为15-445的个数。\n在lab中需要实现count、sum、max、min操作,其实就是在SimpleAggregationHashTable::CombineAggregateValues中实现对应的操作即可,本质上是对哈希表的几个很简单的操作。\naggregation通常需要对一组数据进行计算,这些计算具有以下特点:\n需要完整输入:Aggregation 通常需要从下层拉取所有相关数据才能计算结果,例如计算 SUM 需要遍历所有行。 阻塞性:在传统实现中,Aggregation 算子通常被称为“阻塞算子”,因为它必须等待所有输入数据都拉取完成才能产出结果。这意味着 next() 调用会被延迟,直到聚合计算完成。 在我们的火山模型中 aggregation 是阻塞算子:\n当上层算子调用 next() 时,aggregation 会向下层算子连续调用 next(),直到拉取完全部数据并完成聚合。 在数据尚未完全拉取并聚合完成之前,上层的 next() 调用无法直接返回结果。 需要注意的是:\nSQL中进行group by后使用count,统计的是每组数据中的记录数,而非分组后新表的行数。 distinct其实就是对某个字段进行group by操作 count(*)统计null,而count(字段)不统计null NestedLoopJoin Inner Join:\nbustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE; === BINDER === BoundSelect { table=BoundCrossProductRef { left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 } }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=(__mock_table_1.colA=__mock_table_3.colE), limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) Filter { predicate=(#0.0=#0.2) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Inner, predicate=true } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) bustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1 INNER JOIN __mock_table_3 ON colA = colE; === BINDER === BoundSelect { table=BoundJoin { type=Inner, left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 }, condition=(__mock_table_1.colA=__mock_table_3.colE) }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) 可以看到,即便我们不加inner join,默认情况下也是使用的Inner Join:NestedLoopJoin中的type均为Inner。\nLeft Join:\nbustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1 LEFT OUTER JOIN __mock_table_3 ON colA = colE; === BINDER === BoundSelect { table=BoundJoin { type=Left, left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 }, condition=(__mock_table_1.colA=__mock_table_3.colE) }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Left, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Left, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) 由于火山模型中的Next每次返回一条tuple,所以我们需要在Init中得到join后的所有tuple。Nested Loop Join其实就是用两个for循环去遍历两张表,保存满足筛选条件的tuple。\ninner join只会返回两个表中满足连接条件的tuple;而left join会返回左表中的所有记录,以及右表中满足条件的tuple。所以在嵌套for循环中,如果右表中有未能满足条件的tuple,那么就保存左表中的每一列的值,并且加上右表中每一列的null值。\nTask3 - HashJoin Executor and Optimization 为什么需要hash join呢?如果两张要进行join的表非常大,这时使用NLJ的时间复杂度就为O(n*m),但是如果我们使用hash join,先对一张表建立哈希映射,然后再对另一张表进行哈希检测来判断是否满足连接条件,这样就只要各扫描一次两张表,时间复杂度就降为了O(n+m)。\n将 NL Join 优化为 Hash Join 实验指导中说,优化器需要将 nl join 优化为 hash join 的情况为:连接条件中有多个等值 AND 操作,即 x = y AND a = b AND ...。那我们只要在碰到 NestedLoopPlanNode 时获取其谓词,判断一下是否满足该情况就好了。\n需要注意的是,当谓词仅为 a = b 时,这个谓词是一个 ComparationExpression;而当它为 x = y AND a = b AND ... 时,它是一个 LogicExpression。这里在判断的是否需要区分,而在之前的实验中,有学习到可以使用标准库中的 std::dynamic_pointer_cast 函数来将谓词从 Expression基类 转换为指定的 Expression派生类,当转换失败时,会返回空指针,这样我们可以通过返回的指针来判断是否是想要的 Expression,如果为空,就代表是另一个 Expression。\n由于谓词中很有可能是 LogicExpression 嵌套着 LogicExpression 和 ComparationExpression,数量也不确定,且子 LogicExpression 很可能还有嵌套,这样的话使用递归来处理就非常方便。\n实现 Hash Join hash join 的核心就是:先对一张表建立哈希映射,然后再对另一张表进行哈希检测来判断是否满足连接条件。哈希表中的 key 为连接条件中对应的值的拼接,value 为整个 tuple。而一张表中的所有 tuple,很有可能会有几个列是完全相同的,如果这些列刚好作为连接条件,那进行哈希时就会造成 key 相同的情况,这就造成了哈希碰撞。\n解决方法也很简单:\n使用 std::unordered_map,将拼接的列值作为 key,而值的类型为 std::vector\u0026lt;Tuple\u0026gt;; 使用std::unordered_multimap来处理。 当我们构建好哈希表后,每次获取了另一张表中的 tuple 后进行一次哈希检测,如果 key 存在,需要将所有 value 都进行连接拼接。同 nested loop join 一样,需要特殊处理连接类型为 left 的情况。\nTask4 - Sort + Limit Executors + Window Functions + Top-N Optimization Sort 这个算子的实现思路很简单,但是需要注意两点:\norder by 后面可以跟多个关键字,也就是需要在 std::sort 中对多个关键字进行排序(利用 for 循环)。 这些关键字中可能进行算数运算(算数对象类型为 ArithmeticExpression,搞清楚进行运算的函数就可)。 Limit 这个就更简单了,根据 limit 的返回对应数量的 tuple。\nTopN topn 算子的优化只有 sort 和 limit 同时出现的是否才会触发,这里的优化逻辑比较简单。\n而具体的算子的实现其实就是一个优先队列。举个例子,现在 tuple 按照某个关键字升序排列,并且 limit 为 10,那么就可以构造一个小根堆,并维护其大小最多为10,最后留在堆中的就是结果。如果力扣刷了一点题的话很容易就能理解这里。\nWindow Function 说来惭愧,学 SQL 的时候并不知道窗口函数这个东西\u0026hellip;简单来理解,就是在使用聚合函数的后面加上 over ([可选操作]) 即可对区间进行聚合操作。\n对于下面的 SQL,最后输出的 schema 应该和 WindowFunc.columns 相同,并且行数也和子 executor 返回的行数相同(下文将子 executor 返回的 tuple 称为 child_tuples),只是多了一些额外的计算列。\nbustub\u0026gt; explain(o) select v1, min(v1) over () as min_v1, max(v1) over () as max_v1, count(v1) over () as count_v1, sum(v1) over () as sum_v1 from t1; === OPTIMIZER === WindowFunc { columns=#0.0, placeholder, placeholder, placeholder, placeholder, , window_functions={ 1=\u0026gt;{ function_arg=#0.0, type=min, partition_by=[], order_by=[] }, 2=\u0026gt;{ function_arg=#0.0, type=max, partition_by=[], order_by=[] }, 3=\u0026gt;{ function_arg=#0.0, type=count, partition_by=[], order_by=[] }, 4=\u0026gt;{ function_arg=#0.0, type=sum, partition_by=[], order_by=[] } } } SeqScan { table=t1 } 在这条 SQL 中,返回的 tuple 的格式应该为:\n这条 tuple 的 v1 列的值 所有 tuple 中最小的 v1 的值 所有 tuple 中最大的 v1 的值 所有 tuple 中 v1 的个数 所有 tuple 中 v1 的值的和 这其实和 WindowFunc.columns 有很大的关系,placeholder 说明这只是个占位符,其应该为 window_functions[下标] 对应的窗口函数。例如第二个 placeholder 在 columns 的下表为 1,其对应的窗口函数就是 { function_arg=#0.0, type=min, partition_by=[], order_by=[] }。那么就可以通过 column 来找到每个窗口对应的哈希表。\n️需要注意的是:\n如果按照 ORDER BY 进行排序后,每一行的窗口范围从第一行开始扩展到当前行 否则每一行的窗口范围是整个child_tuples 既然如此,我们需要先从 child_executor 获取到所有的 child_tuples,然后对 child_tuples 遍历:如果有分组行为,那就需要按照某一列(或多列)进行分组,然后对于每一个 tuple,都让其执行一次 WindowFunc.window_functions 中的窗口函数。\n稍有不同的是,之前的 aggregation 操作分组后可能需要完成多个聚合函数,但是这里我们分组之后,只会完成一个聚合函数,因为每个窗口中只有一个聚合函数。\n现在看来,这个窗口函数也无非就是对某个范围内的 tuple 进行分组和聚合操作,实验指导中也提示我们可以去利用 task2 中写的代码。在我的实现方案中,并不像 task2 中只使用一个哈希表,因为在一条 SQL 函数中,可能有多个窗口函数,每一个窗口函数又可能又不同的分组和不同的聚合操作,因此我对于每一个窗口函数都设置了一个哈希表。\n该 task 中 bustub 很仁慈地简化了难度:如果窗口函数任意一个中有 order by,那么所有窗口函数的 order by 都相同。不过在有排序和没有排序的情况下,窗口的范围有所不同,处理起来的方法也不同。\n无排序的情况 这个情况下,每个窗口函数的范围就是整个 child_tuples。之后再次遍历每一条 tuple,按照输出的 schema 来构建返回的 tuple。那么实现操作应该如下:\n先描所有 tuple,生成这条 tuple 对应的 key 和 value,再把 key 和 value 加入对应的哈希表。\n然后对于每一条 tuple,遍历 WindowFunc.columns:\n如果 column 不是 placeholder,那么说明这个位置是这个 tuple 中的一列,获取这一列对应的 Value 就好。 如果 column 是 placeholder,那么说明这个位置的值应该是对应的窗口函数的执行结果,那么从 column 对应的哈希表中找出这个 tuple 对应的值就 ok。 有排序的情况 在这个情况下,排序号后每条 tuple 的范围是自身及之前的所有 tuple,而不是像之前一样的所有 tuple。这就有点像一句话:“走一步看一步”。\n还是遍历每一条 tuple,对每一条 tuple 又遍历 WindowFunc.columns:\n如果 column 不是 placeholder,那么说明这个位置是这个 tuple 中的一列,获取这一列对应的 Value 就好。这里和无排序的情况一致。 如果 column 是 placeholder, 生成这条 tuple 对应的 key 生成这条 tuple 对应的 value,并将 {key, value} 插入 column 对应的哈希表(在 task2 中可知,这里的“插入”其实是对 key 处的旧值 old_value 与新值 value 进行聚合操作) 从哈希表中取出 key 对应的 value 这里的 2.2 和 2.3 就是之前所说的“走一步看一步”:当前 tuple 的结果是在之前的 tuple 上聚合而来的。\n总结 这个 Project 需要我们去深入理解 bustub 的源码,知道一条 SQL 会被解析成一棵什么样的 plan 树,在经过基于规则的优化器优化后才会是最终的物理 plan 树,这时又要去理解这棵树上每个节点对应的算子应该是怎么实现的。其实知道了 plan 树是什么样子后,节点对应的算子就按照要求去设计就好了。\n在众多 AI 例如 ChatGPT、DeepSeek、Kimi 等帮助下,还是慢慢理解并完成了这个 Project!但是有的地方我可能还没有做的比较好,以后有时间再优化一下。\n","permalink":"https://kerolt.github.io/posts/%E6%95%B0%E6%8D%AE%E5%BA%93/cmu15-445-fall2023project3-query-execution-%E5%B0%8F%E7%BB%93/","summary":"该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\n这个project和之前就不一样了,开始深入数据库内核的实现了。需要理清楚一条sql语句是如何被执行的,方才能写出代码。\n前置奶酪 一条SQL语句的执行 这里需要去看看一条sql语句传入bustub内部之后的代码:src/common/bustub_instance.cpp:ExecuteSqlTxn:\nauto BustubInstance::ExecuteSqlTxn(const std::string \u0026amp;sql, ResultWriter \u0026amp;writer, Transaction *txn, std::shared_ptr\u0026lt;CheckOptions\u0026gt; check_options) -\u0026gt; bool { if (!sql.empty() \u0026amp;\u0026amp; sql[0] == \u0026#39;\\\\\u0026#39;) { // 处理元命令 ... } // binder,但是在其中会使用libpg_query来解析sql语句 bustub::Binder binder(*catalog_); binder.ParseAndSave(sql); // 经过上一步后,binder中的statement_nodes_存储着所有的语句解析节点 for (auto *stmt : binder.statement_nodes_) { // 将stmt转换成BoundStatement对象,方便后面处理数据 auto statement = binder.BindStatement(stmt); // 只有不需要构建plan树、不需要进行优化的sql语句才会在switch之后继续执行 switch (statement-\u0026gt;type_) { ... } // 生成初步的执行计划 bustub::Planner planner(*catalog_); planner.PlanQuery(*statement); // 优化刚刚的执行计划 bustub::Optimizer optimizer(*catalog_, IsForceStarterRule()); auto optimized_plan = optimizer.Optimize(planner.plan_); .","title":"【CMU15-445 Fall2023】Project3 Query Execution 小结"},{"content":"最近把博客的构建工具从Hexo换成了Hugo,感觉Hugo配置和使用起来更简洁方便。\n由于我的博客总体来说有两个仓库,一个私有仓库是放置建站工具的目录,其中包含博客 Markdown 内容、一些配置还有主题;另一个就是通过 GitHub Pages 来访问公共仓库。为了方便,之前在使用 Hexo 的使用使用了 Github Actions 来一键部署博客,换成 Hugo 后这个 actions 需要修改一下。\n.github/workflows/hugo.yml:\nname: GitHub Pages on: push: branches: - master # 监听 master 分支的推送事件 pull_request: jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: submodules: true # 拉取 Hugo 主题子模块 fetch-depth: 0 # 获取完整提交历史 - name: Setup Hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: \u0026#39;0.126.2\u0026#39; extended: true - name: Build run: hugo --minify # 启用压缩优化 - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: personal_token: ${{ secrets.ACCESS_TOKEN }} # 仓库访问令牌 external_repository: kerolt/kerolt.github.io # 发布的仓库地址 PUBLISH_BRANCH: master # 部署分支 PUBLISH_DIR: ./public # Hugo 输出目录 关键点说明:\n子模块处理\nsubmodules: true,确保能正确拉取 Hugo 主题(因为我的博客主题是通过 git submodule 添加的) Hugo 扩展版\nextended: true ,因为大多数 Hugo 主题需要 Sass/SCSS 支持 安全凭证\n通过 secrets.ACCESS_TOKEN 实现安全部署,需要在 Repo Settings → Secrets 中添加一个具有 repo 权限的 Personal Access Token 双仓库模式\n使用 external_repository 将构建结果发布到独立的 GitHub Pages 仓库,实现源码与部署分离 ","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/hugo%E9%85%8D%E7%BD%AEgithub-actions/","summary":"最近把博客的构建工具从Hexo换成了Hugo,感觉Hugo配置和使用起来更简洁方便。\n由于我的博客总体来说有两个仓库,一个私有仓库是放置建站工具的目录,其中包含博客 Markdown 内容、一些配置还有主题;另一个就是通过 GitHub Pages 来访问公共仓库。为了方便,之前在使用 Hexo 的使用使用了 Github Actions 来一键部署博客,换成 Hugo 后这个 actions 需要修改一下。\n.github/workflows/hugo.yml:\nname: GitHub Pages on: push: branches: - master # 监听 master 分支的推送事件 pull_request: jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: submodules: true # 拉取 Hugo 主题子模块 fetch-depth: 0 # 获取完整提交历史 - name: Setup Hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: \u0026#39;0.126.2\u0026#39; extended: true - name: Build run: hugo --minify # 启用压缩优化 - name: Deploy uses: peaceiris/actions-gh-pages@v3 with: personal_token: ${{ secrets.","title":"Hugo配置Github Actions"},{"content":"微信在不久前终于有了Linux原生版本,我的电脑是Fedora41,之前安装的是flatpak打包的微信,现在在官网下载rpm包后运行发现无法使用fcitx的中文输入法,找了一下是环境遍历的问题。\n需要添加的环境变量为:\nexport XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export GTK_IM_MODULE=\u0026#34;fcitx\u0026#34; export QT_IM_MODULE=\u0026#34;fcitx\u0026#34; 但是在KDE6 Wayland下如果把它写入/etc/profile中好像会有问题?所以我把这个环境变量放到wechat.desktop中去,相当于给/usr/bin/wechat这个程序进行隔离(重点在Exec中):\n[Desktop Entry] Name=wechat Name[zh_CN]=微信 Exec=env XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; GTK_IM_MODULE=\u0026#34;fcitx\u0026#34; QT_IM_MODULE=\u0026#34;fcitx\u0026#34; /usr/bin/wechat %U StartupNotify=true Terminal=false Icon=/opt/wechat/icons/wechat.png Type=Application Categories=Utility; Comment=Wechat Desktop Comment[zh_CN]=微信桌面版 ","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/linux%E4%B8%8B%E5%BE%AE%E4%BF%A1%E6%97%A0%E6%B3%95%E4%BD%BF%E7%94%A8%E4%B8%AD%E6%96%87%E8%BE%93%E5%85%A5%E6%B3%95%E9%97%AE%E9%A2%98%E8%A7%A3%E5%86%B3/","summary":"\u003cp\u003e微信在不久前终于有了Linux原生版本,我的电脑是Fedora41,之前安装的是flatpak打包的微信,现在在官网下载rpm包后运行发现无法使用fcitx的中文输入法,找了一下是环境遍历的问题。\u003c/p\u003e","title":"Linux下微信无法使用中文输入法问题解决"},{"content":"为了 CMake Tool 能调试代码,先装好 codelldb 插件,然后还需要一个 launch.json 文件,以下内容可以一键配置好调试:\n{ \u0026#34;version\u0026#34;: \u0026#34;0.2.0\u0026#34;, \u0026#34;configurations\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;LLDB\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;lldb\u0026#34;, \u0026#34;request\u0026#34;: \u0026#34;launch\u0026#34;, \u0026#34;program\u0026#34;: \u0026#34;${command:cmake.launchTargetPath}\u0026#34;, \u0026#34;args\u0026#34;: [], \u0026#34;cwd\u0026#34;: \u0026#34;${workspaceFolder}\u0026#34;, } ] } ","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/%E5%9C%A8vscode%E4%B8%AD%E9%85%8D%E7%BD%AElldb/","summary":"为了 CMake Tool 能调试代码,先装好 codelldb 插件,然后还需要一个 launch.json 文件,以下内容可以一键配置好调试:\n{ \u0026#34;version\u0026#34;: \u0026#34;0.2.0\u0026#34;, \u0026#34;configurations\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;LLDB\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;lldb\u0026#34;, \u0026#34;request\u0026#34;: \u0026#34;launch\u0026#34;, \u0026#34;program\u0026#34;: \u0026#34;${command:cmake.launchTargetPath}\u0026#34;, \u0026#34;args\u0026#34;: [], \u0026#34;cwd\u0026#34;: \u0026#34;${workspaceFolder}\u0026#34;, } ] } ","title":"在VSCode中配置LLDB"},{"content":"什么是mmap? mmap 是一种用于将文件或设备与进程的地址空间关联起来的内存映射技术。通过 mmap,可以将文件的内容直接映射到进程的虚拟内存地址空间,使得文件的内容可以像操作普通内存一样进行读取和写入。\n在Linux中,虚拟内存的布局如下:\n图片来源:小林coding\n当我们在Linux上使用mmap系统调用时,得到的文件映射就会放在图中的“文件映射与匿名映射区”。每当我们需要读取或修改文件时,只需要去操作这一块虚拟内存即可,而省去了将文件的内容从磁盘读取到内核缓冲区,然后再拷贝到用户空间的缓冲区,这大大减小了资源开销。\n系统调用参数说明 该lab希望我们实现xv6上的mmap和munmap系统调用,其函数声明为:\nvoid *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t len); 这与Linux上的使用是相同的,对其中的参数解释如下:\nvoid *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);:\naddr (void *): 这是建议的映射起始地址。通常设置为 NULL,由内核自动选择合适的地址。如果指定了非空地址,则内核尽量在这个地址处创建映射(但不保证)。(xv6中不要求实现,addr只要考虑为0/NULL的情况) 如果使用了 MAP_FIXED 标志,则必须将映射建立在 addr 所指向的地址,否则映射会失败。(xv6中不要求实现) len (size_t): 要映射的内存长度(以字节为单位)。如果不是页大小的倍数,通常会向上舍入到最近的页边界。 prot (int): 映射区域的保护权限。可以是以下权限的组合: PROT_READ: 映射区域可读。 PROT_WRITE: 映射区域可写。 PROT_EXEC: 映射区域可执行。 PROT_NONE: 映射区域不可访问。 flags (int): 控制映射对象的类型、映射页是否可共享、映射是否同步到磁盘等。常见的标志有: MAP_SHARED: 共享映射,对映射区域的修改会同步到底层文件,其他映射到同一文件的进程也会看到修改。 MAP_PRIVATE: 私有映射,对映射区域的修改不会影响底层文件,修改是写时复制的(Copy-On-Write)。 MAP_ANONYMOUS: 创建一个匿名映射,与文件无关。fd 参数被忽略,通常与 MAP_PRIVATE 结合使用。(xv6不要求实现) fd (int): 打开的文件描述符,表示要映射的文件。如果使用 MAP_ANONYMOUS 标志,则此参数被忽略,通常设为 -1。 offset (off_t): 文件映射的起始偏移量。必须是页大小的整数倍。(xv6中不要求实现,即只要输入0) int munmap(void *addr, size_t len):\naddr (void *): 要解除映射的起始地址。这个地址必须是由之前的 mmap 调用返回的地址,或者是由 mmap 创建的某个映射区域的地址。 len (size_t): 要解除映射的内存长度,必须与 mmap 调用中的 len 相匹配。如果长度小于 mmap 时指定的长度,可能会导致部分映射区域仍然保留。 如何实现? 在xv6的虚拟内存布局中,可以看到堆区和trapframe之间有一片没有使用的区域,我们可以拿它作为文件映射区域。(xv6和Linux的虚拟内存布局有点区别,xv6的堆区在栈区上面)\n当使用mmap系统调用时,也可以使用懒分配的策略(类似于Copy On Write):我们在mmap系统调用中 标识(不是分配) 文件映射区中有一个区域与文件相关联,但这时还不会分配物理块,自然还不急着将文件读入这片内存区域;当我们需要访问这片区域的内存时,可以通过触发page fault来分配物理块,然后读入文件内容到内存块中,并将虚拟内存映射到这块物理内存上。\n使用munmap系统调用时,会解除文件在映射区[addr, addr + len]范围内的映射,将这块区域的内存写回文件,并释放掉这块内存。实验中保证释放的区域大小一定是页的整数倍。\n我们也仿照Linux上的,让文件映射区从高地址处开始向低地址增长。下图是文件映射的样子,左边为映射区域大小不固定,右边为映射大小为页框的整数倍:\n在实验的提示中,有说到mmaptest中没有使用的功能可以不实现,其中每次使用mmap都是映射的PGSIZE的整数倍,那也就说明我们可以之用考虑右边的情况,这让实验降低了一点复杂度。\n标识映射区域 根据实验提示,我们需要为每个进程设置用于标识映射区域的结构体:\n// proc.h #define NVMA 16 struct vma { uint len; // 映射区域大小 uint prot; // 映射区域的保护权限 struct file *file; // 需要映射的文件 int used; // 是否被使用 int flags; // 映射类型 int offset; // 偏移量 uint64 start; // 映射区域开始的地址 uint64 end; // 映射区域结束的地址 }; struct proc { ... struct vma vmas[NVMA]; // Virtual memory area }; 实现sys_mmap 在此之前,我们需要先注册mmap和munmap系统调用,这里我们就不赘述了\n获得映射区中的可用区域 什么意思呢?我们的映射区设计的是从高地址向低地址增长,那么我们每次需要增长时,最简单的就是在已有的映射区中找到地址最低的,并将新的映射区放在其之后,即地址最低的映射区的start就是新的映射区的end:\n可是这样的算法有很大的问题:如果我们取消了文件2的映射后,有一个只需要一个页框的映射区,按照这个算法它会被安排到文件3的映射区下面,这样就浪费了之前释放的映射区。\n不过嘛,在这个实验中这么做没什么问题😜,如果想知道更好的方法,可以参考这篇博客。\n我的实现如下:\n// sysfile.c // 获取一个可使用的vma的end地址 static uint64 vma_end() { struct proc *p = myproc(); struct vma *v = 0; uint64 min_vma_end = TRAPFRAME; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; p-\u0026gt;vmas[i].end \u0026lt;= min_vma_end) { min_vma_end = p-\u0026gt;vmas[i].end; v = \u0026amp;p-\u0026gt;vmas[i]; } } // 如果进程中还没有文件映射,就从trapframe后开始设置映射区 if (!v) { return min_vma_end; } // 这里可以直接返回v-\u0026gt;start,这样做可以处理映射区域大小不固定的情况(应该吧) return PGROUNDDOWN(v-\u0026gt;start); } sys_mmap 虽然刚刚我们有了可以获取映射区地址的函数,但是这个系统调用并不用真正分配内存,它只需要进行标记vma即可。\n找到一个可以使用的vma区域的end地址 初始化vma 返回vma的start地址 这里我觉得最重要的就是设置start和end地址,一个映射区的范围为[start, end),其长度就为len,通过vma_end函数我们可以获取新映射区的end地址,再通过end - len即可得到start地址。\n// sysfile.c uint64 sys_mmap(void) { // void *mmap(void *addr, int len, int prot, int flags, int fd, int offset); uint64 addr; int len, prot, flags, fd, offset; struct proc *p = myproc(); struct file *f; argaddr(0, \u0026amp;addr); argint(1, \u0026amp;len); argint(2, \u0026amp;prot); argint(3, \u0026amp;flags); argfd(4, \u0026amp;fd, \u0026amp;f); argint(5, \u0026amp;offset); if (addr \u0026lt; 0 || len \u0026lt; 0 || prot \u0026lt; 0 || flags \u0026lt; 0 || fd \u0026lt; 0 || offset \u0026lt; 0) { return -1; } if (!f-\u0026gt;readable \u0026amp;\u0026amp; (prot \u0026amp; PROT_READ) \u0026amp;\u0026amp; (flags \u0026amp; MAP_SHARED)) { return -1; } if (!f-\u0026gt;writable \u0026amp;\u0026amp; (prot \u0026amp; PROT_WRITE) \u0026amp;\u0026amp; (flags \u0026amp; MAP_SHARED)) { return -1; } // 找到一个可用的vma struct vma *v = 0; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used == 0) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { return -1; } // 初始化vma uint64 end = vma_end(); v-\u0026gt;len = len; v-\u0026gt;prot = prot; v-\u0026gt;file = f; v-\u0026gt;used = 1; v-\u0026gt;flags = flags; v-\u0026gt;offset = offset; v-\u0026gt;end = end; v-\u0026gt;start = end - len; // 有文件映射时,对应的文件的引用计数也+1 filedup(f); return v-\u0026gt;start; } 懒分配策略 找到触发fault的地址,并据此找到对应的vma 校验 分配物理内存块 设置权限 读取文件内容到内存块中,注意偏移量 设置物理内存与虚拟内存的映射 // trap.c // 处理mmap的懒分配策略 static int handle_mmap_fault(uint64 addr) { struct proc *p = myproc(); struct vma *v = 0; // 根据触发fault的地址,并据此找到对应的vma for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; addr \u0026gt;= p-\u0026gt;vmas[i].start \u0026amp;\u0026amp; addr \u0026lt; p-\u0026gt;vmas[i].end) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { printf(\u0026#34;no no no\\n\u0026#34;); return -1; } // 校验 if (!v-\u0026gt;file-\u0026gt;readable \u0026amp;\u0026amp; r_scause() == 13 \u0026amp;\u0026amp; (v-\u0026gt;flags \u0026amp; MAP_SHARED)) { return -1; } if (!v-\u0026gt;file-\u0026gt;writable \u0026amp;\u0026amp; r_scause() == 15 \u0026amp;\u0026amp; (v-\u0026gt;flags \u0026amp; MAP_SHARED)) { return -1; } // 设置内存块权限 uint perm = PTE_V | PTE_U; if (v-\u0026gt;prot \u0026amp; PROT_READ) { perm |= PTE_R; } if (v-\u0026gt;prot \u0026amp; PROT_WRITE) { perm |= PTE_W; } if (v-\u0026gt;prot \u0026amp; PROT_EXEC) { perm |= PTE_X; } // 分配物理块 char *pa = kalloc(); if (!pa) { return -1; } memset(pa, 0, PGSIZE); // 读取文件内容到内存块 uint offset = addr - v-\u0026gt;start; ilock(v-\u0026gt;file-\u0026gt;ip); if (readi(v-\u0026gt;file-\u0026gt;ip, 0, (uint64)pa, offset, PGSIZE) == 0) { iunlock(v-\u0026gt;file-\u0026gt;ip); return -1; } iunlock(v-\u0026gt;file-\u0026gt;ip); // 设置虚拟内存与物理内存的映射 mappages(p-\u0026gt;pagetable, PGROUNDDOWN(addr), PGSIZE, (uint64)pa, perm); return 0; } 然后在usertrap中处理读写造成的page fault:\n// trap.c void usertrap(void) { ... if(r_scause() == 8){ ... } else if((which_dev = devintr()) != 0){ // ok } else if (r_scause() == 13 || r_scause() == 15) { if (handle_mmap_fault(r_stval()) != 0) { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } } else { ... } .. } 实现sys_munmap sys_munmap sys_munmap需要将内存块中的内容写回文件,并释放这个内存块。这里我们将这个操作额外封装一层,即不将具体实现放在sys_munmap中,这是因为在进程销毁也需要使用这个操作。\n// sysfile.c uint64 sys_munmap(void) { // int munmap(void *addr, int len); uint64 addr; int len; argaddr(0, \u0026amp;addr); argint(1, \u0026amp;len); if (addr \u0026lt; 0 || len \u0026lt; 0) { return -1; } return munmap(addr, len); } 解除映射 遍历所有的vma,找到addr所在的vma,要求addr不能是vma区域的中间位置,可以是开头和结束位置。 使用mmap_writeback将这addr的内容写回对应的文件 更新vma的范围 如果vma的len小于等于0,说明该文件的映射已经结束,可以关闭文件了,同时这个vma也应该释放了 // vm.c // 解除区域 [addr, addr + len) 的文件映射 uint64 munmap(uint64 addr, int len) { struct proc *p = myproc(); struct vma *v = 0; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; addr \u0026gt;= p-\u0026gt;vmas[i].start \u0026amp;\u0026amp; addr \u0026lt; p-\u0026gt;vmas[i].end) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { return -1; } // 不在合法的位置 if (addr \u0026gt; v-\u0026gt;start \u0026amp;\u0026amp; addr + len \u0026lt; v-\u0026gt;end) { return -1; } // 将映射区域写回文件 mmap_writeback(p-\u0026gt;pagetable, addr, len, v); // 修改映射区域大小 if (addr == v-\u0026gt;start) { v-\u0026gt;start += len; } else if (addr == v-\u0026gt;end - len) { v-\u0026gt;end = addr; } v-\u0026gt;len -= len; // 映射区域大小为0 if (v-\u0026gt;len \u0026lt;= 0) { fileclose(v-\u0026gt;file); v-\u0026gt;used = 0; } return 0; } 将映射区内容写回文件 遍历这个vma中的所有页框,对于其中的每一个页帧,获取对应的pte,需要考虑到由于懒分配带来的影响。 如果这个页帧被修改过,并且这块vma的策略是可写,那么就将这个页写回文件,注意偏移量 释放这块页帧对应的物理内存 // 将映射区域写回文件,并释放映射区域的内存 static int mmap_writeback(pagetable_t pgtbl, uint64 src_va, int len, struct vma *vma) { pte_t *pte; uint64 addr; // 遍历区域的页框 for (addr = PGROUNDDOWN(src_va); addr \u0026lt; PGROUNDDOWN(src_va + len); addr += PGSIZE) { // 获取页帧对应的pte if ((pte = walk(pgtbl, addr, 0)) == 0) { panic(\u0026#34;mmap_writeback\u0026#34;); } // 这是为了处理这样一种情况:使用了mmap系统调用却没有有访问映射的文件,由于懒分配的策略, // 在写回文件时vma虽然有效,但是对应的pte并没有设置PTE_V,映射区域也还没有真正的映射文件 if (!(*pte \u0026amp; PTE_V)) { continue; } // 映射区域被修改了,可以写回文件 if ((*pte \u0026amp; PTE_D) \u0026amp;\u0026amp; (vma-\u0026gt;flags \u0026amp; MAP_SHARED)) { begin_op(); ilock(vma-\u0026gt;file-\u0026gt;ip); uint offset = addr - src_va; writei(vma-\u0026gt;file-\u0026gt;ip, 1, addr, offset, PGSIZE); iunlock(vma-\u0026gt;file-\u0026gt;ip); end_op(); } kfree((void *)PTE2PA(*pte)); *pte = 0; } return 0; } 我们使用到了pte中的一个标志位PTE_D,它是用来标识一个页框是否被修改了(即脏位),我们需要在riscv.h中定义它:\n// riscv.h #define PTE_D (1L \u0026lt;\u0026lt; 7) 在exit时需要清空映射区 当进程退出时,其映射区中的内容也需要释放,这也是为什么要将munmap独立出来的原因。\n// proc.c void exit(int status) { struct proc *p = myproc(); if(p == initproc) panic(\u0026#34;init exiting\u0026#34;); for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used) { if (munmap(p-\u0026gt;vmas[i].start, p-\u0026gt;vmas[i].len) != 0) { panic(\u0026#34;exit: munmap\u0026#34;); } } } ... } 在fork时需要“复制”映射区 我们这里所说的复制并不是将映射区的内存块在fork时都复制给子进程,可别忘了COW哦,我们只需要复制父进程中的vma数组,知道映射的哪些位置有什么样的文件映射,在真正访问时再按需加载即可。\n// proc.c int fork(void) { ... for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used) { np-\u0026gt;vmas[i] = p-\u0026gt;vmas[i]; // 子进程也映射了和父进程相同的文件,那么这个文件的引用计数也要增加 filedup(p-\u0026gt;vmas[i].file); } } ... } Code Details 代码实现详情请见:Github\nReference https://xiaolincoding.com/os/3_memory/linux_mem.html#_3-%E8%BF%9B%E7%A8%8B%E8%99%9A%E6%8B%9F%E5%86%85%E5%AD%98%E7%A9%BA%E9%97%B4 https://ttzytt.com/2022/08/xv6_lab11_record/index.html Summary 这个lab的代码还是比较多的,不过它还给我们放了些水,只让我们实现一些基础的功能。在lab中更重要的是要搞清楚mmap的实现原理,一定要去理解其中的细节。\n","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab10-mmap/","summary":"\u003ch2 id=\"什么是mmap\"\u003e什么是mmap?\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003emmap\u003c/code\u003e 是一种用于将文件或设备与进程的地址空间关联起来的内存映射技术。通过 \u003ccode\u003emmap\u003c/code\u003e,可以将文件的内容直接映射到进程的虚拟内存地址空间,使得文件的内容可以像操作普通内存一样进行读取和写入。\u003c/p\u003e","title":"【MIT6.S081】Lab10 mmap"},{"content":"Intro 在这个实验中,我们需要让xv6支持更大的文件和软链接。实验总体不是特别难,不过需要我们理解好文件系统是如何工作的。Lab8 lock中的Buffer Cache也是文件系统的一部分,不过它位于文件系统的下层,这里我们需要处理的更多在上层,偏应用层。\nLarge files 如何扩大单个文件的大小上限? 要求我们扩大单个文件的大小,实现机制就是通过修改inode的块索引,将原来的12个直接索引和一个一级间接索引变为11个直接索引、一个一级间接索引和一个二级间接索引。\n在xv6中,一个数据块的大小为1KB,由于间接块中存放的是下一个块的地址(其实个人认为更准确的说是块号),使用的类型是uint,那么一个间接块能存放这样的地址共1KB / sizeof(uint) = 1KB / 4B = 256个。\n这样的话,单个文件大小最大为12 * 1KB + 256 * 1KB = 268KB变为了11 * 1KB + 256 * 1KB + 256 * 256 * 1KB = 65803KB。\n修改inode结构体 其数据块地址中:有11个直接块地址,1个一级间接块地址,1个二级间接块地址\n在原本的代码中,表示直接块数量的宏为NDIRECT,其定义在fs.h中,我们需要修改其值为11,并且由于我们扩大了文件大小上限,所以也要修改对应的宏MAXFILE,扩大后的应该为:NDIRECT + NINDIRECT + NINDIRECT * NINDIRECT;同时还要修改inode结构体:\n// fs.h #define NDIRECT 11 #define NINDIRECT (BSIZE / sizeof(uint)) #define MAXFILE (NDIRECT + NINDIRECT + NINDIRECT * NINDIRECT) // On-disk inode structure struct dinode { short type; // File type short major; // Major device number (T_DEVICE only) short minor; // Minor device number (T_DEVICE only) short nlink; // Number of links to inode in file system uint size; // Size of file (bytes) uint addrs[NDIRECT+2]; // Data block addresses }; 内存中保存的inode结构体也需要修改:\n// file.h // in-memory copy of an inode struct inode { uint dev; // Device number uint inum; // Inode number int ref; // Reference count struct sleeplock lock; // protects everything below here int valid; // inode has been read from disk? short type; // copy of disk inode short major; short minor; short nlink; uint size; uint addrs[NDIRECT+2]; }; 修改bmap函数 bmap函数会根据传入参数的数据块号来返回其对应的地址\n我们在bmap中看到如果数据块还没有分配的时候,会使用balloc来分配,并返回分配的块的块号。\n这个函数我一开始看的时候对于如何分配的比较奇怪,后来在AI的帮助下慢慢理解了。在xv6中的硬盘布局是这样的:\n[ boot block | super block | log | inode blocks | free bit map | data blocks] 在学习操作系统时,我们有学到过一个叫位图的东西,将多个块合并作为一个位图,其中的每一位(bit)用来唯一表示一个数据块是否被使用了(例如0表示为使用,1表示使用)。在xv6中,我们在需要分配数据块时,会去位图中寻找可用的数据块的编号,在去获取对应的数据块:\nstatic uint balloc(uint dev) { int b, bi, m; struct buf *bp; bp = 0; // 文件系统总共有 200000 个块(sb.size = 200000),且每个位图块可以管理 8192 个块(BPB = 8192) // 在第一次迭代中,b = 0,读取管理第 0 到第 8191 个块的位图块。 // 内层循环遍历这 8192 个块,寻找空闲块。 // 在第二次迭代中,b = 8192,读取管理第 8192 到第 16383 个块的位图块。 for(b = 0; b \u0026lt; sb.size; b += BPB){ bp = bread(dev, BBLOCK(b, sb)); // 获取在第n次迭代中管理一个BPB大小的位图块 // 遍历这个位图块中的每一位,找到未使用的块号并返回 for(bi = 0; bi \u0026lt; BPB \u0026amp;\u0026amp; b + bi \u0026lt; sb.size; bi++){ m = 1 \u0026lt;\u0026lt; (bi % 8); if((bp-\u0026gt;data[bi/8] \u0026amp; m) == 0){ // Is block free? bp-\u0026gt;data[bi/8] |= m; // Mark block in use. log_write(bp); brelse(bp); bzero(dev, b + bi); return b + bi; } } brelse(bp); } printf(\u0026#34;balloc: out of blocks\\n\u0026#34;); return 0; } 要实现大文件的bmap,我们可以仿照原本的bmap是如何处理一级间接块的:\nstatic uint bmap(struct inode *ip, uint bn) { ... // 块号(bn)减去11个,这样是为了方便之后可以按偏移值为0开始计算 bn -= NDIRECT; // 如果bn超出了11个,也就说明需要通过一级间接块去获取 if(bn \u0026lt; NINDIRECT){ // Load indirect block, allocating if necessary. // ip-\u0026gt;addrs[11]代表了一级间接块的地址 if((addr = ip-\u0026gt;addrs[NDIRECT]) == 0){ // 此时还未分配一级间接块,分配 addr = balloc(ip-\u0026gt;dev); if(addr == 0) return 0; ip-\u0026gt;addrs[NDIRECT] = addr; } // 从硬盘中读出这个块 bp = bread(ip-\u0026gt;dev, addr); // 获取这个块中的数据(不过这里只是通过指针来更好的操作) a = (uint*)bp-\u0026gt;data; if((addr = a[bn]) == 0){ // 如果还没有分配bn所表示的数据块,分配 addr = balloc(ip-\u0026gt;dev); if(addr){ a[bn] = addr; log_write(bp); } } brelse(bp); return addr; } ... } 同样的,二级间接块也是类似的操作手法:\nstatic uint bmap(struct inode *ip, uint bn) { ... // 如果bn超出了11个,也就说明需要通过一级间接块去获取 if(bn \u0026lt; NINDIRECT){ ... } // 如果运行到了这里,说明要从二级间接块中寻找,这里减去NINDIRECT(256)也是像之前一样方便计算 bn -= NINDIRECT; // 从二级间接块中找 if (bn \u0026lt; NINDIRECT * NINDIRECT) { // inode的二级间接块还未分配 if ((addr = ip-\u0026gt;addrs[NDIRECT + 1]) == 0) { addr = balloc(ip-\u0026gt;dev); if(addr == 0) return 0; ip-\u0026gt;addrs[NDIRECT + 1] = addr; } int level1 = bn / NINDIRECT; int level2 = bn % NINDIRECT; // 读取二级间接块 bp = bread(ip-\u0026gt;dev, addr); a = (uint *)bp-\u0026gt;data; if ((addr = a[level1]) == 0){ a[level1] = addr = balloc(ip-\u0026gt;dev); log_write(bp); // 修改了就要记录日志 } brelse(bp); bp = bread(ip-\u0026gt;dev, addr); a = (uint *)bp-\u0026gt;data; if ((addr = a[level2]) == 0) { a[level2] = addr = balloc(ip-\u0026gt;dev); log_write(bp); // 修改了就要记录日志 } brelse(bp); return addr; } } 多的步骤就是二级间接索引需要多一次读取数据块和查找:\n假设传入bmap的块号为524,那么其不在直接块中,也不在一级间接块中,而是在二级间接块中。通过bmap中的前两个if之后对于bn的减法操作,此时的bn = 524 - 11 - 256 = 257。那么在二级间接块的第一层中(level1),索引为257 / 256 = 1,在第二层中(level2)索引为257 % 256 = 1 。如下图所示:\nSymbolic links 首先要搞清楚软硬链接的区别:\n软链接(符号链接): 软链接是一种特殊的文件,它包含指向另一个文件或目录的路径。它类似于Windows中的快捷方式。 存储的是目标文件或目录的路径信息。 硬链接: 硬链接是文件系统中的一个普通文件名,只是它与其他文件名指向同一个物理文件数据块。 存储的是目标文件的数据本身,不包含路径信息。 那么要我们实现软链接,我们需要做的事情有两个:\n实现软链接的系统调用 处理使用open打开一个软链接文件 系统调用的实现 实验中让我们实现的系统调用接受连个参数(char *target, char *path),它在path处创建了一个软链接,该链接指向文件名为target的文件。\n如何注册系统调用啥的这里就不赘述了。\n首先我们需要准备一些宏:\n// stat.h #define T_SYMLINK 4 // symbolic link // fcntl.h #define O_NOFOLLOW 0x800 实现sys_symlink系统调用:\n我们创建一个软链接时需要使用create新建一个inode,因为软链接也是一个文件;然后调用writei将目标路径的字符串target写入软链接文件的数据块。\n需要注意的是在完成这两步之后需要使用iunlockput(ip)来释放在create中加上的锁。\nuint64 sys_symlink(void) { char target[MAXPATH], path[MAXPATH]; if (argstr(0, target, MAXPATH) \u0026lt; 0 || argstr(1, path, MAXPATH) \u0026lt; 0) { return -1; } begin_op(); // 软链接是一个特殊的文件,其存储的数据为目标文件的路径信息,因此我们在创建软链接时需要创建一个新的inode struct inode *ip = create(path, T_SYMLINK, 0, 0); if (ip == 0) { end_op(); return -1; } // 将target字符串写入inode的第一个直接块中 if (writei(ip, 0, (uint64)target, 0, strlen(target)) \u0026lt; 0) { end_op(); return -1; } iunlockput(ip); end_op(); return 0; } 处理open一个软链接的情况 这里可能是这个lab的一个难点了。为什么我们需要处理这样的情况呢?\n还记得我们刚刚准备的一个宏O_NOFOLLOW吗,在Linux中,它有这样些特点:\n符号链接检查:当O_NOFOLLOW标志被设置时,如果指定的文件名是一个软链接,打开操作将不会跟随这个链接,而是会失败,并返回一个错误。 错误返回:如果尝试打开一个软链接,并且O_NOFOLLOW标志被设置,系统会返回ELOOP错误,表示遇到了太多的符号链接。 用途:这个标志通常用于安全目的,防止应用程序无意中打开一个软链接,这可能被恶意用户用来绕过安全限制。 例如:\nint fd = open(\u0026#34;/path/to/symlink\u0026#34;, O_RDONLY | O_NOFOLLOW); 如果/path/to/symlink是一个软链接,这个调用将失败。\n我们需要在sys_open中处理:当需要打开的文件类型是软链接类型并且没有使用O_NOFOLLOW标志时,递归找到最终的目标文件。为什么说是“递归”呢,因为可能有这样一种情况:一个软链接指向了另一个软链接,甚至如此以往。为了避免“子子孙孙无穷无尽”的情况,我们需要设置一个递归层数的限制,当然,我们这里使用循环来代替递归。\nuint64 sys_open(void) { ... if(ip-\u0026gt;type == T_DEVICE \u0026amp;\u0026amp; (ip-\u0026gt;major \u0026lt; 0 || ip-\u0026gt;major \u0026gt;= NDEV)){ ... } // 文件的类型是软链接,且没有使用O_NOFOLLOW标志,就递归地查找,直到找到最终的目标文件的inode // 否则,就当作是正常的文件进行打开 if (ip-\u0026gt;type == T_SYMLINK \u0026amp;\u0026amp; !(omode \u0026amp; O_NOFOLLOW)) { // 如果循环10次后找到的文件类型还是软链接,那么说明有错误了 for (int i = 0; i \u0026lt; 10; i++) { // 从软链接文件的inode中读取目标文件的路径字符串 // 注意,在这一步之前已经对ip上锁,因此这里操作完后需要解锁 if (readi(ip, 0, (uint64)path, 0, strlen(path)) \u0026lt; 0) { iunlockput(ip); end_op(); return -1; } iunlockput(ip); ip = namei(path); if (ip == 0) { end_op(); return -1; } ilock(ip); if (ip-\u0026gt;type != T_SYMLINK) { break; } } if (ip-\u0026gt;type == T_SYMLINK) { iunlockput(ip); end_op(); return -1; } } if((f = filealloc()) == 0 || (fd = fdalloc(f)) \u0026lt; 0){ ... } ... } Code Detail 代码实现详情请见Github:\nLarge files Symbolic links Reference https://pdos.csail.mit.edu/6.S081/2023/labs/fs.html https://xv6.dgs.zone/labs/answers/lab9.html Summary 文件系统是一个有意思的东西,同时也很复杂,xv6中设计了一个简化的文件系统,并采用了分层的设计。虽然这里将实验做完了,但是我觉得自己还有很多细节没有搞懂。例如其中的日志层,其是如何实现crash之后能恢复过来的代码是如何设计编写的,还有其中的锁的获取与释放等等。希望之后能结合实际再分析分析。\n","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab9-file-system/","summary":"\u003ch2 id=\"intro\"\u003eIntro\u003c/h2\u003e\n\u003cp\u003e在这个实验中,我们需要让xv6支持更大的文件和软链接。实验总体不是特别难,不过需要我们理解好文件系统是如何工作的。\u003ccode\u003eLab8 lock\u003c/code\u003e中的Buffer Cache也是文件系统的一部分,不过它位于文件系统的下层,这里我们需要处理的更多在上层,偏应用层。\u003c/p\u003e","title":"【MIT6.S081】Lab9 file system"},{"content":"Intro 这个实验个人感觉挺难的,需要我们重新设计数据结构,还要考虑在并发(并行)情况下对于锁的操作,以减少多核情况下对于锁的竞争。其中主要涉及内存分配和IO缓冲块分配,在这个lab之前,xv6对于这两个分配都是使用的全局对象,并只有一把全局锁进行操作,这样的话在并行情况下锁的竞争是很激烈的,我们的任务就是重新设计这两个分配器,它们的重构思路并不完全一致,需要具体问题具体分析。\nMemory Allocator 在xv6中,内存通过kalloc()来分配,在其内部,会使用一个kmem的结构体变量:\n// kernel/kalloc.c struct run { struct run *next; }; struct { struct spinlock lock; struct run *freelist; } kmem; kmem.freelist保存着未使用的内存块(每块大小为4KB),在内核初始化时,会通过kinit()将地址KERNBASE ~ PHYSTOP的物理内存(一共128MB)放入freelist中:\nvoid kinit() { initlock(\u0026amp;kmem.lock, \u0026#34;kmem\u0026#34;); freerange(end, (void*)PHYSTOP); // end为kernel.ld中定义的,值为0x80000000,即KERNBASE } void freerange(void *pa_start, void *pa_end) { char *p; p = (char*)PGROUNDUP((uint64)pa_start); for(; p + PGSIZE \u0026lt;= (char*)pa_end; p += PGSIZE) kfree(p); // 使用kfree将内存块放入freelist中 } 通过上面的源码分析我们可以看到,整个xv6内核都是通过一个全局的kmem来分配和回收内存块,那么如果当多个CPU(多个core)需要操作内存块时,就必须得用锁才能保证整体的稳定和正确。\n但是这样做又会有一个大问题,那就是一个cpu在操作kmem时,另一个CPU即便想获取内存块或释放内存块,因为锁的缘故也只能等待。故我们的解决方案是为每个CPU都设置一个kmem,这样,哪个CPU需要操作内存块时就可以只锁上它自己的kmem,其他CPU受到的干扰会大大减少。\n为什么说是大大减少而不是完全减少呢?这是因为当某个CPU的kmem.freelist中没有可分配的内存块时,需要去其他CPU的kmem.freelist中去拿一个过来,这时就需要处理好这两个CPU的锁的处理了。\nstruct { struct spinlock lock; struct run *freelist; char lock_name[8]; // 每个kmem的锁的名称 } kmem[NCPU]; // 为每个CPU都设置一个kmem void kinit() { // 初始化时要对每个kmem都初始锁 for (int i = 0; i \u0026lt; NCPU; i++) { initlock(\u0026amp;kmem[i].lock, \u0026#34;kmem\u0026#34;); snprintf(kmem[i].lock_name, sizeof(kmem[i].lock_name), \u0026#34;kmem%d\u0026#34;, i); } // 这里会先将所有内存块都分配给kmem[0],因为内核启动时是cpus[0]在做初始化操作 // 之后其他CPU需要内存块时,从cpus[0]这里拿 freerange(end, (void*)PHYSTOP); } void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa \u0026lt; end || (uint64)pa \u0026gt;= PHYSTOP) panic(\u0026#34;kfree\u0026#34;); // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); r = (struct run*)pa; // ---------------------------------------- // kfree时只需要将内存块放到自己所属cpu的kmem.freelist中 push_off(); int cid = cpuid(); acquire(\u0026amp;kmem[cid].lock); r-\u0026gt;next = kmem[cid].freelist; kmem[cid].freelist = r; release(\u0026amp;kmem[cid].lock); pop_off(); // ---------------------------------------- } void * kalloc(void) { struct run *r; // ---------------------------------------- push_off(); int cid = cpuid(); acquire(\u0026amp;kmem[cid].lock); r = kmem[cid].freelist; if (r) { // 如果当前的freelist中还有内存块,则直接用 kmem[cid].freelist = r-\u0026gt;next; } else { // 没有?那就拿! // 遍历一下其他CPU的kmem,如果找到的freelist中还有内存块,就拿它的 for (int next_cid = 0; next_cid \u0026lt; NCPU; next_cid++) { // 不找自己 if (next_cid == cid) continue; acquire(\u0026amp;kmem[next_cid].lock); r = kmem[next_cid].freelist; if (r) { kmem[next_cid].freelist = r-\u0026gt;next; release(\u0026amp;kmem[next_cid].lock); break; } release(\u0026amp;kmem[next_cid].lock); } } release(\u0026amp;kmem[cid].lock); pop_off(); // ---------------------------------------- if(r) memset((char*)r, 5, PGSIZE); // fill with junk return (void*)r; } Buffer Cache 先介绍下实验初始的bcache:\nstruct { struct spinlock lock; struct buf buf[NBUF]; // Linked list of all buffers, through prev/next. // Sorted by how recently the buffer was used. // head.next is most recent, head.prev is least. struct buf head; } bcache; struct spinlock lock:bcache的全局锁 struct buf buf[NBUF]:缓冲块池,即包含了所有的buffer cache struct buf head:一个LRU链表,用于操作缓冲块,使用head.next获取的是最近刚使用过的buffer,head.prev获取的是最近未使用时间最久的buffer(或者说是未被使用的buffer,即引用计数为0) 原来的bcache中的buf数组在binit时就给LRU链表初始化用了:\nvoid binit(void) { struct buf *b; initlock(\u0026amp;bcache.lock, \u0026#34;bcache\u0026#34;); // Create linked list of buffers bcache.head.prev = \u0026amp;bcache.head; bcache.head.next = \u0026amp;bcache.head; for(b = bcache.buf; b \u0026lt; bcache.buf+NBUF; b++){ b-\u0026gt;next = bcache.head.next; b-\u0026gt;prev = \u0026amp;bcache.head; initsleeplock(\u0026amp;b-\u0026gt;lock, \u0026#34;buffer\u0026#34;); bcache.head.next-\u0026gt;prev = b; bcache.head.next = b; } } 而这样设计的buffer cache有一个问题,由于buffer cache只有一个全局锁,当在高并发的情况下,进程需要并发访问bcache时,无法达到多进程带来的优势,一个进程必须要先等前一个进程释放锁后才可以操作。\n考虑前一个实验中的kalloc,我们能否也使用那样的策略?\nReducing contention in the block cache is more tricky than for kalloc, because bcache buffers are truly shared among processes (and thus CPUs). For kalloc, one could eliminate most contention by giving each CPU its own allocator; that won\u0026rsquo;t work for the block cache.\n为什么这么说呢?是因为block cache并不像内存页那样具有通用性。block cache对应着真实的物理外存块,每一个CPU都可能使用同一个block cache,所以无法像kalloc中那样为每个CPU分配其对应的cache。\n既然我们无法分别为每个CPU分配,那么我们可以换一个角度,对所有的block块进行分组:我们可以创建一个哈希桶hash buckets(这里使用的容量为13),那么对每个block块的块号进行取余操作(mod 13)即可将它们映射到其中一个哈希桶中。这样一来,我们需要使用标号为blockno的块时,到其映射的哈希桶中去寻找即可,并且,在并发情况下,我们一般上锁的单位从整个bcache变为了其中的一个桶,锁的粒度大大减小了。\n那么我们修改一下bcache的数据结构:\n#define NBUCKET 13 #define BLOCK_HASH(blockno) (blockno % NBUCKET) struct { struct spinlock g_lock; // 全局锁,这里相较之前只是改了个名字 struct buf buf[NBUF]; struct spinlock bk_lock[NBUCKET]; // 每个hash bucket都对应有一把锁 struct buf bucket[NBUCKET]; // hash bucket int size; // 缓冲块池(buf[NBUF])中已使用的块数 } bcache; 我们在重构的方案中,不再使用LRU链表,但还是使用了LRU算法的思想,只是用时间戳来代替LRU链表:\nstruct buf { int valid; // has data been read from disk? int disk; // does disk \u0026#34;own\u0026#34; buf? uint dev; uint blockno; struct sleeplock lock; uint refcnt; // struct buf *prev; // LRU cache list struct buf *next; uchar data[BSIZE]; uint timestamp; }; 这个时间戳通过一个全局变量ticks(trap.c)来获取:\nextern uint ticks; 那么在初始化bcache时,我们只需要初始化其中的锁和bcache.size即可:\nvoid binit(void) { struct buf *b; bcache.size = 0; initlock(\u0026amp;bcache.g_lock, \u0026#34;bcache\u0026#34;); for (int i = 0; i \u0026lt; NBUCKET; i++) { initlock(\u0026amp;bcache.bk_lock[i], \u0026#34;bk_lock\u0026#34;); } for(b = bcache.buf; b \u0026lt; bcache.buf+NBUF; b++){ initsleeplock(\u0026amp;b-\u0026gt;lock, \u0026#34;buffer\u0026#34;); } } 由于我们对于锁的操作单位变为了bucket,所以下面两个函数也需要修改:\nvoid bpin(struct buf *b) { int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[idx]); } void bunpin(struct buf *b) { int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt--; release(\u0026amp;bcache.bk_lock[idx]); } 在原先的释放buffer块的brelse(struct buf *b)函数中,会先对b的引用计数-1,当其值为0时,将其转移至LRU链表的head-\u0026gt;prev位置,说明这个buffer没有被任何进程使用了。而在重构方案中,我们使用时间戳代替了LRU链表,每个bucket中我们并没有维护一个LRU链表,而是认为引用计数为0且时间戳最小的的buffer是没有被任何进程使用的,故在引用计数为0时,只需要更新buffer的时间戳即可:\nvoid brelse(struct buf *b) { if(!holdingsleep(\u0026amp;b-\u0026gt;lock)) panic(\u0026#34;brelse\u0026#34;); releasesleep(\u0026amp;b-\u0026gt;lock); int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt--; if (b-\u0026gt;refcnt == 0) { // no one is waiting for it. b-\u0026gt;timestamp = ticks; // 未使用的buffer的时间戳一定是最小的 } release(\u0026amp;bcache.bk_lock[idx]); } 对于bcache最重要的bget()函数,我们重构的思路为:\n检查标号为blockno的块的缓存是否在cache中,如果在,则其引用计数+1,返回该缓存块 在缓存中没有找到,先在缓冲块池中寻找还未分配给bucket的缓存块 如果缓冲块池中都分配出去了,就到每个bucket中去找。如果当前遍历的bucket中有buffer的引用计数为0,拿到其中时间戳最小的buffer(这也就是说这个buffer是距离现在最久没有使用的合法块) 如果这个buffer在原先的bucket中,返回这个buffer 否则需要将这个buffer从当前bucket中转移到目标bucket中 这是一个大致的思路,具体细节参考如下代码:\nstatic struct buf* bget(uint dev, uint blockno) { struct buf *b; int bucket_idx = BLOCK_HASH(blockno); // 先对blockno对应的bucket上锁即可 acquire(\u0026amp;bcache.bk_lock[bucket_idx]); // Is the block already cached? for(b = \u0026amp;bcache.bucket[bucket_idx]; b; b = b-\u0026gt;next){ if(b-\u0026gt;dev == dev \u0026amp;\u0026amp; b-\u0026gt;blockno == blockno){ b-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;b-\u0026gt;lock); return b; } } // 在缓存中没有找到,先在缓冲块池中寻找还未分配给bucket的缓存块 // 需要使用全局锁来保证bcache.size++的原子性 // 这里也是为什么buffer时间戳不需要初始化的原因:每一次需要使用buffer时(会调用bget), // 如果buffer池中还有空闲buffer,则会直接使用这个buffer,完成操作后会调用relese释放buffer,此时就会更新buffer的时间戳 acquire(\u0026amp;bcache.g_lock); if (bcache.size \u0026lt; NBUF) { struct buf *b = \u0026amp;bcache.buf[bcache.size++]; b-\u0026gt;next = bcache.bucket[bucket_idx].next; bcache.bucket[bucket_idx].next = b; b-\u0026gt;dev = dev; b-\u0026gt;blockno = blockno; b-\u0026gt;valid = 0; b-\u0026gt;refcnt = 1; release(\u0026amp;bcache.g_lock); acquiresleep(\u0026amp;b-\u0026gt;lock); return b; } release(\u0026amp;bcache.g_lock); // 在这时才能释放该bucket的锁,假设在检查缓存是否存在后释放: // 如果有两个进程1和2,此时缓冲块池还有多个未分配的块,进程1检查bucket,发现没有缓存,释放锁,准备去缓冲块池中拿 // 此时切换到进程2,进程2检查bucket,发现没有缓存,也去缓冲块池中拿 // 这样就会导致bucket会添加两个blockno的缓冲块 release(\u0026amp;bcache.bk_lock[bucket_idx]); // 在每个bucket中去找可用的buffer cache for (int i = 0; i \u0026lt; NBUCKET; i++) { struct buf *cur_buf, *pre_buf, *min_buf, *min_pre_buf; uint min_timestamp = -1; acquire(\u0026amp;bcache.bk_lock[bucket_idx]); pre_buf = \u0026amp;bcache.bucket[bucket_idx]; cur_buf = pre_buf-\u0026gt;next; // 遍历bcache.bucket[bucket_idx] while (cur_buf) { // 为什么这里需要重新检查?考虑这样一种情况: // 假设缓冲块池中还有一个未分配的,此时有两个进程,进程1和2都需要访问同一个标号blockno的块 // 进程1先拿到这个未分配的,并将其放入了对应的bucket中 // 之后进程2发现池中没有未分配的了,开始遍历所有bucket, // 如果这时不重新检查一下blockno对应的bucket,则会导致一个bucket中有两个blockno的块 if (bucket_idx == BLOCK_HASH(blockno) \u0026amp;\u0026amp; cur_buf-\u0026gt;blockno == blockno \u0026amp;\u0026amp; cur_buf-\u0026gt;dev == dev) { cur_buf-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;cur_buf-\u0026gt;lock); return cur_buf; } // 只有引用计数为0,并且时间戳最小的缓冲块才可被重新分配 if (cur_buf-\u0026gt;refcnt == 0 \u0026amp;\u0026amp; cur_buf-\u0026gt;timestamp \u0026lt; min_timestamp) { min_pre_buf = pre_buf; min_buf = cur_buf; min_timestamp = cur_buf-\u0026gt;timestamp; } pre_buf = cur_buf; cur_buf = cur_buf-\u0026gt;next; } // 在本轮中找到了可重新分配的缓冲块 if (min_buf) { min_buf-\u0026gt;dev = dev; min_buf-\u0026gt;blockno = blockno; min_buf-\u0026gt;valid = 0; min_buf-\u0026gt;refcnt = 1; // 是自身bucket中的,不用做转移操作 if (bucket_idx == BLOCK_HASH(blockno)) { // release(\u0026amp;bcache.hash_lock); release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;min_buf-\u0026gt;lock); return min_buf; } // 是其他bucket中的,需要转移 // 先将目标bucket中的buffer移除,然后释放锁 min_pre_buf-\u0026gt;next = min_buf-\u0026gt;next; release(\u0026amp;bcache.bk_lock[bucket_idx]); // 接着获取blockno对应的锁,并将从目标bucket中移除的buffer放至blockno对应的bucket,返回该buffer bucket_idx = BLOCK_HASH(blockno); acquire(\u0026amp;bcache.bk_lock[bucket_idx]); min_buf-\u0026gt;next = bcache.bucket[bucket_idx].next; bcache.bucket[bucket_idx].next = min_buf; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;min_buf-\u0026gt;lock); return min_buf; } release(\u0026amp;bcache.bk_lock[bucket_idx]); // 如果到底了,从头来,保证遍历每一个bucket if (++bucket_idx == NBUCKET) { bucket_idx = 0; } } panic(\u0026#34;bget: no buffers\u0026#34;); } bget是这个lab中最复杂的地方,其处理逻辑虽然较好理解,但是多个锁的操作顺序很让人头疼,需要考虑到多种并发情况。\nCode Details 代码实现详情请见Github:https://github.com/kerolt/xv6-labs-2023/commit/ccac48e8ae6b6dde3e7c747a77f4149232420901\nReference https://github.com/whileskies/xv6-labs-2020/blob/main/doc/Lab8-locks.md https://blog.csdn.net/LostUnravel/article/details/121430900 ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab8-lock/","summary":"\u003ch2 id=\"intro\"\u003eIntro\u003c/h2\u003e\n\u003cp\u003e这个实验个人感觉挺难的,需要我们重新设计数据结构,还要考虑在并发(并行)情况下对于锁的操作,以减少多核情况下对于锁的竞争。其中主要涉及内存分配和IO缓冲块分配,在这个lab之前,xv6对于这两个分配都是使用的全局对象,并只有一把全局锁进行操作,这样的话在并行情况下锁的竞争是很激烈的,我们的任务就是重新设计这两个分配器,它们的重构思路并不完全一致,需要具体问题具体分析。\u003c/p\u003e","title":"【MIT6.S081】Lab8 lock"},{"content":"在做thread lab的时候,阅读xv6的源码后对于进程调度的实现有了大致的了解,但是其中锁的获取与释放顺序让我困惑了好久:在yield函数中,不是先获取了进程p的锁吗,那么之后在调度器中又获取p的锁,那不是会死锁吗?在调度器内使用swtch发生进程切换后,又会跳转到哪里?\n而在我观摩大佬的一些博客和视频后,发现我之前的想法有很大的问题,归根结底是没有弄明白xv6何时发生了切换,切换后应该从哪里开始运行。这篇笔记就是对于分析xv6进程调度的总结。\n在使用allocproc创建进程时,会为创建的proc设置ra的值,初始会设置为forkret函数的地址。\nstatic struct proc* allocproc(void) { ... memset(\u0026amp;p-\u0026gt;context, 0, sizeof(p-\u0026gt;context)); p-\u0026gt;context.ra = (uint64)forkret; p-\u0026gt;context.sp = p-\u0026gt;kstack + PGSIZE; return p; } 在调度器scheduler开始运行后,遍历进程表,先对遍历到的当前进程p上锁,如果这个进程是可运行的(RUNNABLE),则执行swtch函数切换上下文,保存当前cpu的上下文,同时加载p的上下文到寄存器中。\n执行完swtch后会跳转到ra寄存器所保存的地址,也就是这个进程初始设置的forkret的位置,这个函数中会释放在scheduler中对进程p上的锁。如果这个函数是第一次执行,那么会初始化文件系统。之后执行usertrapret函数。\n// A fork child\u0026#39;s very first scheduling by scheduler() // will swtch to forkret. void forkret(void) { static int first = 1; // Still holding p-\u0026gt;lock from scheduler. release(\u0026amp;myproc()-\u0026gt;lock); if (first) { // File system initialization must be run in the context of a // regular process (e.g., because it calls sleep), and thus cannot // be run from main(). fsinit(ROOTDEV); first = 0; // ensure other cores see first=0. __sync_synchronize(); } usertrapret(); } usertrapret中,将会关闭中断,最后这会让当前进程从内核态返回到用户态(回到用户空间),切换用户页表并恢复用户态寄存器。\n// // return to user space // void usertrapret(void) { ... // we\u0026#39;re about to switch the destination of traps from // kerneltrap() to usertrap(), so turn off interrupts until // we\u0026#39;re back in user space, where usertrap() is correct. intr_off(); ... // jump to userret in trampoline.S at the top of memory, which // switches to the user page table, restores user registers, // and switches to user mode with sret. uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline); ((void (*)(uint64))trampoline_userret)(satp); } 在之后如果该进程会触发中断(例如时间片轮转调度中执行时间到了后触发时钟中断)或者使用了系统调用,将会执行usertrap函数(也即处理陷入),在其中会执行yield函数来让该进程让出cpu,并进行进程切换。\n// // handle an interrupt, exception, or system call from user space. // called from trampoline.S // void usertrap(void) { ... if(r_scause() == 8){ // system call ... } else if((which_dev = devintr()) != 0){ // ok } else { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } if(killed(p)) exit(-1); // give up the CPU if this is a timer interrupt. if(which_dev == 2) yield(); usertrapret(); } yield函数会先对当前进程p上锁,然后执行shed函数,shed函数会先进行一些检查,然后调用swtch函数进行上下文切换,其保存p的上下文,并加载之前cpu的上下文。这样,ra寄存器中的值就变成了scheduler中的swtch所在地址的后一条指令的地址。在schduler中,重置当前cpu所运行的进程后,释放进程p的锁,之后开启新一轮调度(遍历进程表找到下一个可运行的进程)。\nvoid sched(void) { int intena; struct proc *p = myproc(); if(!holding(\u0026amp;p-\u0026gt;lock)) panic(\u0026#34;sched p-\u0026gt;lock\u0026#34;); if(mycpu()-\u0026gt;noff != 1) panic(\u0026#34;sched locks\u0026#34;); if(p-\u0026gt;state == RUNNING) panic(\u0026#34;sched running\u0026#34;); if(intr_get()) panic(\u0026#34;sched interruptible\u0026#34;); intena = mycpu()-\u0026gt;intena; swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context); mycpu()-\u0026gt;intena = intena; } // Give up the CPU for one scheduling round. void yield(void) { struct proc *p = myproc(); acquire(\u0026amp;p-\u0026gt;lock); p-\u0026gt;state = RUNNABLE; sched(); release(\u0026amp;p-\u0026gt;lock); } 这里yield中使用了acquire和release函数来进行锁的操作,但是这个锁的获取与释放时机并不是如同代码中的这样是一个“顺序”的过程。\n当执行顺序为从调度器选择可调度进程(RUNNABLE)后进行进程切换: 当执行顺序为进程触发中断等陷入时,放弃cpu资源,需要执行进程调度时: 可以看到,在yield函数中,进程p的锁的使用顺序并不是acquire() 获取锁-\u0026gt; 执行shed()函数 -\u0026gt; release()释放锁这样线性的,在其中会发生进程切换因此锁的“获取、释放”逻辑并不发生在一个函数中。\n","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081xv6%E8%BF%9B%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%88%86%E6%9E%90/","summary":"\u003cp\u003e在做thread lab的时候,阅读xv6的源码后对于进程调度的实现有了大致的了解,但是其中锁的获取与释放顺序让我困惑了好久:在yield函数中,不是先获取了进程p的锁吗,那么之后在调度器中又获取p的锁,那不是会死锁吗?在调度器内使用swtch发生进程切换后,又会跳转到哪里?\u003c/p\u003e\n\u003cp\u003e而在我观摩大佬的一些博客和视频后,发现我之前的想法有很大的问题,归根结底是没有弄明白xv6何时发生了切换,切换后应该从哪里开始运行。这篇笔记就是对于分析xv6进程调度的总结。\u003c/p\u003e","title":"【MIT6.S081】xv6进程调度分析"},{"content":"本次的实验总体都不是很难,第一个练习让我们在用户态模拟了线程的切换,这里重要的就是进程/线程上下文的保存与恢复;第二三个练习则是让我们跳出了xv6,去熟悉pthread库和线程的同步互斥。\n简单分析xv6中的进程切换 init是用户态最先启动的进程,其启动后会创建sh进程,sh进程又会fork并exec其他命令:\n内核会在userinit()中准备init进程,这是最先启动的用户态进程,将其运行状态设置为RUNNABLE 内核启动scheduler进行进程调度(这个函数会一直执行),当前只有init进程,且其状态为RUNNABLE,故运行 init进程会fork一个子进程,并使用exec执行sh进程,其用于处理用户在控制台的输入 用户输入命令后,sh解析命令后fork子进程并用exec来将命令对应的进程替换掉fork的子进程 init - sh - ls - cat - ... xv6内部采用时间片轮转来进行进程调度,当进程时间片到了后,触发时钟中断,执行yield,在yield中,会将当前进程的状态从运行态RUNNING设置为RUNNABLE,并切换至scheduler调度器,由其来寻找下一个可运行的进程进行切换。\n那么xv6是怎么切换当前进程和scheduler调度器的呢?其实现是根据swtch函数来实现的:\n// Switch to scheduler. Must hold only p-\u0026gt;lock // and have changed proc-\u0026gt;state. Saves and restores // intena because intena is a property of this // kernel thread, not this CPU. It should // be proc-\u0026gt;intena and proc-\u0026gt;noff, but that would // break in the few places where a lock is held but // there\u0026#39;s no process. void sched(void) { int intena; struct proc *p = myproc(); if(!holding(\u0026amp;p-\u0026gt;lock)) panic(\u0026#34;sched p-\u0026gt;lock\u0026#34;); if(mycpu()-\u0026gt;noff != 1) panic(\u0026#34;sched locks\u0026#34;); if(p-\u0026gt;state == RUNNING) panic(\u0026#34;sched running\u0026#34;); if(intr_get()) panic(\u0026#34;sched interruptible\u0026#34;); intena = mycpu()-\u0026gt;intena; swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context); mycpu()-\u0026gt;intena = intena; } // Give up the CPU for one scheduling round. void yield(void) { struct proc *p = myproc(); acquire(\u0026amp;p-\u0026gt;lock); p-\u0026gt;state = RUNNABLE; sched(); release(\u0026amp;p-\u0026gt;lock); } swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context)函数会将当前寄存器中的值保存到p-\u0026gt;context中,并将当前cpu中context的内容恢复到寄存器中,其中最重要的就是ra寄存器,当恢复了这个后,ra的值对应的地址在调度器scheduler()中:\n(这里ra的16进制为0x8000141e)\n这样,xv6就从:执行当前进程 -\u0026gt; yield -\u0026gt; shed -\u0026gt; scheduler;在scheduler调度器中,内核会找到下一个就绪态的进程,并使用swtch进行进程切换:\n// Per-CPU process scheduler. // Each CPU calls scheduler() after setting itself up. // Scheduler never returns. It loops, doing: // - choose a process to run. // - swtch to start running that process. // - eventually that process transfers control // via swtch back to the scheduler. void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c-\u0026gt;proc = 0; for(;;){ // The most recent process to run may have had interrupts // turned off; enable them to avoid a deadlock if all // processes are waiting. intr_on(); for(p = proc; p \u0026lt; \u0026amp;proc[NPROC]; p++) { acquire(\u0026amp;p-\u0026gt;lock); if(p-\u0026gt;state == RUNNABLE) { // Switch to chosen process. It is the process\u0026#39;s job // to release its lock and then reacquire it // before jumping back to us. p-\u0026gt;state = RUNNING; c-\u0026gt;proc = p; swtch(\u0026amp;c-\u0026gt;context, \u0026amp;p-\u0026gt;context); // Process is done running for now. // It should have changed its p-\u0026gt;state before coming back. c-\u0026gt;proc = 0; } release(\u0026amp;p-\u0026gt;lock); } } } 这样,xv6又从调度器切换到了下一个进程去执行了。\nUthread: switching between threads 在uthread.c中,我们需要在用户态下模拟了线程的运行与切换。可以简单理解,运行一个线程就是执行了一个函数。\n源代码中已经给出要运行的线程(函数),如何启动它们并对他们进行切换呢?\n前置准备 线程的切换同进程的切换相似,需要保存通用寄存器的值,所以我们可以对内核照猫画虎般在用户线程struct thread中添加一个struct context变量:\nstruct context { uint64 ra; uint64 sp; // callee-saved uint64 s0; uint64 s1; uint64 s2; uint64 s3; uint64 s4; uint64 s5; uint64 s6; uint64 s7; uint64 s8; uint64 s9; uint64 s10; uint64 s11; }; struct thread { char stack[STACK_SIZE]; /* the thread\u0026#39;s stack */ int state; /* FREE, RUNNING, RUNNABLE */ struct context t_context; }; 启动 risc-v中的ra寄存器的作用是保存函数返回地址。具体来说,当一个函数被调用时,调用指令会将返回地址,即调用该函数的下一条指令的地址,保存在ra寄存器中。\n在实验中,我们通过thread_create来创建线程,那么,在执行玩thread_create之后并进行了调度后,是不是就该运行线程函数了?没错,那么我们需要保存创建的线程对应的函数的地址到ra寄存器中,同时,还需要设置线程的栈指针:\nvoid thread_create(void (*func)()) { struct thread *t; for (t = all_thread; t \u0026lt; all_thread + MAX_THREAD; t++) { if (t-\u0026gt;state == FREE) break; } t-\u0026gt;state = RUNNABLE; // YOUR CODE HERE t-\u0026gt;t_context.ra = (uint64)func; t-\u0026gt;t_context.sp = (uint64)t-\u0026gt;stack + STACK_SIZE; } 调度 即切换线程,我们需要保存当前线程的通用寄存器,并将下一个要运行的线程的通用寄存器值恢复到硬件上,如同进程切换那样:\n# uthread_switch.S thread_switch: /* YOUR CODE HERE */ sd ra, 0(a0) sd sp, 8(a0) sd s0, 16(a0) sd s1, 24(a0) sd s2, 32(a0) sd s3, 40(a0) sd s4, 48(a0) sd s5, 56(a0) sd s6, 64(a0) sd s7, 72(a0) sd s8, 80(a0) sd s9, 88(a0) sd s10, 96(a0) sd s11, 104(a0) ld ra, 0(a1) ld sp, 8(a1) ld s0, 16(a1) ld s1, 24(a1) ld s2, 32(a1) ld s3, 40(a1) ld s4, 48(a1) ld s5, 56(a1) ld s6, 64(a1) ld s7, 72(a1) ld s8, 80(a1) ld s9, 88(a1) ld s10, 96(a1) ld s11, 104(a1) ret /* return to ra */ // uthread.c void thread_schedule(void) { ... if (current_thread != next_thread) { /* switch threads? */ next_thread-\u0026gt;state = RUNNING; t = current_thread; current_thread = next_thread; /* YOUR CODE HERE * Invoke thread_switch to switch from t to next_thread: * thread_switch(??, ??); */ thread_switch((uint64)\u0026amp;t-\u0026gt;t_context, (uint64)\u0026amp;next_thread-\u0026gt;t_context); } else next_thread = 0; } Using threads 如果只是简单在get和put操作中加上锁,那么这样虽然能够保证对共享资源的互斥访问,当时无法达到双线程所带来的性能提升。\n应该实现的是两个线程,你放你的,我放我的,现在有5个entry table,我们可以不止使用一把锁来锁住5个table,而是使用5把锁,哪一个table需要互斥操作时只锁它一个就行,这样就细化了锁的粒度。\n这里需要把table改为一个数组,实现代码比较简单,这里就不放了。\nBarrier 实验的要求是每来一个线程运行到barrier就阻塞,直到所有线程都运行到了barrier才释放。这里是想让我们熟悉pthread库中关于锁和条件变量的使用,更加了解线程的同步关系。\n我们可以给全局的bstate中添加一个变量count用于记录当前轮次有多少个线程已经到达了barrier,只要有线程到达,执行++bstate.count,需要注意的是,这个全局变量会被多个线程使用,故需要使用锁来保证互斥访问。\n接下来判断count的值是否等于线程的数量,如果不相等,就wait阻塞当前线程,并释放锁;否则,说明一轮结束,将count置0,轮次+1,并使用broadcaset唤醒被阻塞的线程。\n尝试1 按刚刚说的思路,如果这么写代码,将会造成系统程序死锁。\nstatic void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); ++bstate.count; pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); if (bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); } 当条件变量 cond 变为真且该线程被唤醒时,pthread_cond_wait 会自动重新获取互斥锁 mutex,然后返回。\n线程1第0轮获得锁,++count后释放锁,然后在wait处阻塞,并释放锁 接着线程2在第0轮获得锁,++count后进入if判断,使用broadcast唤醒线程1 线程1被唤醒,并自动获取锁 线程2进入第1轮,尝试获得锁,但是此时线程1正持有锁,故线程2阻塞 线程1进入第1轮,但之前自己已经获得锁了,此时又请求锁,故阻塞 两个线程都阻塞,程序出现死锁 尝试2 既然pthread_cond_wait会自动重新获取互斥锁,那就在调用之后使用pthread_mutex_unlock释放锁不久好了吗?\nstatic void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); if (++bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); } 这样还是不行:原因是pthread_cond_broadcast不会释放锁。\n在第0轮线程1拿到锁后++count后进入wait状态,此时线程1阻塞并释放锁 在第0轮线程2拿到锁,执行++count操作后使用broadcast尝试唤醒线程1,但是线程2并没有释放锁 线程1抢到CPU,但是拿不到锁,继续阻塞 线程2抢到了CPU的执行权进入第1轮,企图获得锁,但是之前自己没有释放锁,也阻塞 两个线程都阻塞,程序出现死锁 正确做法 所以正确的做法是在broadcast后也使用unlock释放锁:\nstatic void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); if (++bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); } 代码实现详情 请见Github:https://github.com/kerolt/xv6-labs-2023/tree/thread\nReference https://www.cnblogs.com/looking-for-zihuatanejo/p/17682582.html https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/lec11-thread-switching-robert/11.7-xv6-switch-function https://www.bilibili.com/video/BV1bZ421U75W/?spm_id_from=333.999.0.0\u0026amp;vd_source=e7bb0cfb7224c8d6671fa62c0e80c832 https://pdos.csail.mit.edu/6.S081/2023/labs/thread.html ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab6-multithreading/","summary":"\u003cp\u003e本次的实验总体都不是很难,第一个练习让我们在用户态模拟了线程的切换,这里重要的就是进程/线程上下文的保存与恢复;第二三个练习则是让我们跳出了xv6,去熟悉pthread库和线程的同步互斥。\u003c/p\u003e","title":"【MIT6.S081】Lab6 multithreading"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\n太菜了,从没打过这么艰难的仗QAQ。由于课程的要求不能公开源代码,所以网上的资源会少很多,平台上的测试案例比较全面,有的还比较刁钻,需要考虑到可拓展哈希的实现细节。在自认为写完了后,提交了近40来次总有几个测试集过不了,还好没有崩溃,在看了几篇博客的方法后,加上自己画图理解,最后终于过了😭。不过回头写博客的时候再去看代码,也没有特别的复杂,还是得明白其中的算法逻辑是如何实现的。\nTask1 - Read/Write Page Guards 简单来说,就是为Page实现一个RAII来自动管理资源。因为在BufferPoolManager::Unpin中,每次调用这个函数,都会让对应的page的pin_count_ - 1,当这个值为0时,这个page就可以被回收,或者说被替换了。但如果我们忘记去手动调用,该页面将永远不会被逐出缓冲池。由于缓冲池以更少的帧数运行,磁盘内外的页面交换将更多。不仅性能受到影响,而且很难检测到错误。\n主要需要考虑如何编写移动构造、移动赋值的逻辑。移动了一个对象后,原来的对象的资源应该转移到了新对象上,那么原来的对象无法再访问资源(将原来对象的资源重置nullptr或清空)。\n还有一个Drop()的接口,这是提供给使用者的释放资源的api,在实现虚构函数时可以直接调用它。Drop的实现就是调用Unpin,然后置空资源。需要注意的是,在进行移动赋值时,一开始也要Drop一下,考虑这样一种情况:\nauto p = std::move(basic_page_guard); p = std::move(basic_page_guard2); 这个时候同一个变量p接管了两个page,那么应该在第二个移动赋值时先drop掉第一个,因为第一个page不再使用了,自然要Unpin。\n还有就是在三个page guard类重载移动赋值时,如果需要移动的对象和自身是同一个,那么直接返回自己就好:\nauto BasicPageGuard::operator=(BasicPageGuard \u0026amp;\u0026amp;that) noexcept -\u0026gt; BasicPageGuard \u0026amp; { if (\u0026amp;that == this) { return *this; } // ... 其他操作 } 在ReadPageGuard和WritePageGuard的Drop()中,还需要考虑释放管理的page的锁。对应的,锁的获取发生在FetchPageWrite()和FetchPageWrite()中。\nTask2 - Extendible Hash Table Pages 为什么我们需要可扩展哈希?\n下图来源:https://www.bilibili.com/video/BV1Qt421w7JT\n在bustub的设计中,Header Page,Directory Page和Bucket Page都是无法直接构造出来的,即不能通过构造函数创建,只能通过各自的PageGuard中的As()或者AsMut()函数来转换。\nHeader header page中有一个max_depth_的成员变量,1 \u0026lt;\u0026lt; max_depth_即为header page中能存放的目录的索引的数量。当我们有值需要放入哈希表时,获取hash(key)的二进制最高max_depth_位作为索引,再从header中对应位置去找到directory。对应的ExtendibleHTableHeaderPage中的功能实现并不难。\nDirectory directory中有两个depth:\nGlobal Depth:若global depth为n,那么这个Directory就有2^n个entry(相当于指向2^n个bucket) Local Depth:若local depth为n,则在这个对应的bucket下,每个元素的key的最后n位都相同 类似header中获取下一级页的索引,directory获取hash(key)的二进制最低global_depth_位作为索引。那local depth的作用是什么呢?\n这就要说到可拓展哈希中的插入和删除操作了。简单来说,在可拓展哈希表中,目录directory的大小是可以变化的(只要不超过最大容量限制)。目录中可能有多个entry映射到同一个bucket。当需要插入时,如果这个bucket还没有满时,可以直接插入;否则,需要将这个bucket分裂成两个bucket(或者说,将这个bucket中的一部分移动到另一个bucket中),并且这个bucket对应的local depth + 1,这样相比之前就多了一位二进制位去识别bucket。具体的用法可以看后面的Task3部分的笔记。\n下面讲几个稍微难懂的函数:\nGetGlobalDepthMask 这个函数的作用是获取global_depth_个二进制1,举个例子,如果global depth是2,那么说明这个目录当前的容量为2^2=4,索引为0~3,用两位二进制就能表示。\n主要用于和hash(key)进行\u0026amp;操作,获取在directory中对应的索引位置。例如,假设key=3,hash(key)=101(二进制),global depth还是2,那么检查hash(key)的最低两位即为01,那么在directory就是第1个entry。\nCanShrink CanShrink() 的核心功能是检测是否可以减少全局深度(global depth),从而缩小哈希表的大小。它基于以下原则:\n在可扩展哈希表中,每个桶(bucket)都有自己的局部深度(local depth),而整个哈希表有一个全局深度(global depth)。如果所有桶的局部深度都小于当前的全局深度,说明哈希表的某些位(超过局部深度的那些位)并没有被实际使用,因而可以安全地减少全局深度。\nGetSplitImageIndex GetSplitImageIndex() 是在可扩展哈希表中用于桶(bucket)拆分时确定拆分后的另一个桶的索引。\n在可扩展哈希表中,当一个桶装满时,目录容量会翻倍,这个桶会拆分成两个桶(但是除了需要拆分的桶,其他目录还是指向原来的桶)。\n每个桶都有一个局部深度(local depth),表示这个桶在哈希表中使用了多少位哈希值来定位数据。桶拆分时,局部深度会增加。 拆分后的桶与当前桶具有相同的局部深度值,只是在第一位上有所不同。例如,如果当前桶的局部深度为 2,那么拆分后的桶与其前后 2 位相同,只有第 1 位不同。 例如,当前桶的索引为 01,局部深度为 3。\n计算翻转位的值:1 \u0026lt;\u0026lt; (local_depth - 1) = 1 \u0026lt;\u0026lt; (3 - 1) = 1 \u0026lt;\u0026lt; 2 = 100 (即二进制的 0100)。 按位异或:bucket_index ⊕ 100 = 001 ⊕ 100 = 101(即二进制的 101,也就是十进制的 5)。 因此,拆分后的桶的索引是 101(5 in decimal)。\nGetLocalDepthMask 这个函数主要是用在bucket的分裂和合并中,用于判断需要操作的bucket中的项在更新后应该属于directory下哪个entry对应的bucket(有点绕)。\n和GetGlobalDepthMask一样,当前directory的entry映射的bucket的local_depth_是多少,其mask的二进制就是多少个1。\nBucket Bucket存储着多个键值对,没有使用标准库的map,而是使用std::pair数组(如果都用标准库了要你实现啥哈希表hh)。\n#define MappingType std::pair\u0026lt;KeyType, ValueType\u0026gt; static constexpr uint64_t HTABLE_BUCKET_PAGE_METADATA_SIZE = sizeof(uint32_t) * 2; constexpr auto HTableBucketArraySize(uint64_t mapping_type_size) -\u0026gt; uint64_t { return (BUSTUB_PAGE_SIZE - HTABLE_BUCKET_PAGE_METADATA_SIZE) / mapping_type_size; }; class ExtendibleHTableBucketPage { ... private: uint32_t size_; uint32_t max_size_; MappingType array_[HTableBucketArraySize(sizeof(MappingType))]; }; 任务就是在这个bucket中增删查对应的key and value,由于内部使用的是定长数组,所以最简单的方法就是顺序操作。\n但是需要注意的是,在bucket page的Insert操作中,注释上给的提示是:“当插入成功时返回true,插入失败或者键已经存在时返回false”。但是如果你按着“先判断bucket是否满了,再遍历bucket中的键值对数组,查找有没有key相同的,最后再插入”这样的逻辑去写,那当你提交时怎么测试都有几个测试集过不了,当时我想得脑子都要炸了也想不清怎么回事。\n在看了一篇博客后,我按照他的逻辑~~“先遍历数组,如果有键值相同的,更新它而不是返回false,然后再判断是否已经满了,没满就再插入”~~去写,最后对了。(这里为啥用了删除线,是因为我的代码是按照这个逻辑来写的,但是在我写这篇博客时感觉不对劲,假如这个键已经存在,且此时bucket没有满,那更新之后又插入了一次,数据有重复)\nTask3 - Extendible Hashing Implementation 细节!细节!还是TMD细节!\n前两个task比较容易写,这个task其实本质上还是一个数据结构的设计实现问题,也就是哈希表如何插入数据和删除数据,但是细节需要注意太多了!如何考虑插入后的分裂,还有删除时的合并。从第一次提交到全部通过一共用了7天(哭)。\nInsert Insert操作需要注意的就是插入失败后的分裂问题,小细节在于下面逻辑步骤中的3,8.5和9(加粗表示):\n检查需要插入的键是否已经存在,如果存在就返回false,否则继续第二步 对key使用hash算法 从header通过hash(key)找到对应的directory page,如果directory不存在,那么创建新的directory page,再创建新的bucket page后执行插入;否则继续第四步。需要注意,如果判断了directory存在后,需要对header page guard进行Drop()操作,因为平台的测试集中有比较刁钻的情况,其buffer manager pool的大小只有3,如果不把header page释放,那么到时候需要进行分裂时无法再获取一个新的page! 从directory通过hash(key)找到对应的bucket page,如果page不存在,那么创建新的bucket page再插入;否则继续第五步 向bucket page中插入键值对,如果插入成功,返回true;否则继续第六步 (循环开始) 检查当前directory是否已经满了,比如说directory的max_depth_是2,那么说明最多只能有四个bucket page,若此时已经有四个bucket了,那么已经满了,无法继续分裂桶;没满继续第八步 分裂桶 先创建新的bucket page作为当前bucket的镜像桶 如果global depth等于当前bucket的local depth,说明需要扩充directory的大小,同时调整扩充后的directory中的entry与bucket的映射 在directory中设置镜像桶,增加原桶和镜像桶的local depth,此时获取原桶的local depth对应的mask 通过mask将原桶拆成两个桶,比如原桶的local depth为2,对应的mask的二进制为$(11)_2$,通过\u0026amp;操作获取桶中每个键值对的hash(key)的最低两位,如果与bucket_index \u0026amp; mask相同,那么就说明这个键值对应该留在原桶,反之应该移至镜像桶 分裂成两个桶后,在使用mask判断需要插入的键值对应该插入那个桶,记录插入是否成功 考虑这样一种情况,需要插入的bucket满了后要进行分裂,但是有可能分裂后原来的键值对还是在一个bucket中,那这个时候插入就失败了,所以需要继续从第七步执行,直到directory满了或者插入成功 (循环结束) return true Remove 由于课程要求不能公开代码,加上网上关于可拓展哈希的操作多只有Insert,而Remove很少有介绍说明的,所以不得不去思考很多情况,加上自己画图去理解,当然还是去看了几篇大佬的博客我才慢慢写出了解决方案。\n实验指导中说了对于Remove的合并需要进行递归的处理,但其实我们用循环去处理就行,至于为什么要递归去处理,下面的步骤9和步骤13中有加粗解释:\n对key使用hash算法 从header通过hash(key)找到对应的directory page,如果directory不存在,return false;否则继续第3步。类似Insert,这里也需要Drop header page 从directory通过hash(key)找到对应的bucket page,如果page不存在,那return false;否则继续第4步 删除bucket中的key对应的键值对,如果删除失败,返回false;否则继续第5步 如果删除成功后bucket不为空,说明不需要合并,返回true;否则继续第6步 (循环开始) 获取当前bucket的镜像bucket,如果二者的local depth不相同,那么结束循环;否则继续第8步 当前桶和镜像桶可以合并,将当前桶的映射更新到镜像桶,并且local depth都 -1,删除当前桶的bucket page 删除了bucket_index对应的page后,虽然调整了其对应的local depth和page id,但是还应该遍历所有的可成为镜像桶的index,如果它们的local depth相同,也应该合并,记录需要合并的的bucket的page id 在遍历时不需要一个一个去遍历directory的entry,而是可以跳着遍历:只有index的后local_depth - 1位相同的才可能需要合并 如果需要合并的的bucket的page id数量为0,说明已经没有可以合并的bucket了,不应该继续合并,可以结束循环了;否则继续第11步 因为又合并了,所以原桶和其镜像桶对应的 local depth 又要 -1 删除需要合并的的bucket的page 从第7步继续,因为合并后的bucket在其local depth - 1后可能会碰到和其local depth相同的bucket,且其中还有空的bucket,这就需要不断去合并(这就是实验中说的递归合并) (循环结束) 缩减目录大小,直到无法缩小 return true Reference https://www.cnblogs.com/wevolf/p/18302985 https://zhuanlan.zhihu.com/p/622221722 https://zhuanlan.zhihu.com/p/701875021 ","permalink":"https://kerolt.github.io/posts/%E6%95%B0%E6%8D%AE%E5%BA%93/cmu15-445-fall2023project2-extendible-hash-index-%E5%B0%8F%E7%BB%93/","summary":"该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\n太菜了,从没打过这么艰难的仗QAQ。由于课程的要求不能公开源代码,所以网上的资源会少很多,平台上的测试案例比较全面,有的还比较刁钻,需要考虑到可拓展哈希的实现细节。在自认为写完了后,提交了近40来次总有几个测试集过不了,还好没有崩溃,在看了几篇博客的方法后,加上自己画图理解,最后终于过了😭。不过回头写博客的时候再去看代码,也没有特别的复杂,还是得明白其中的算法逻辑是如何实现的。\nTask1 - Read/Write Page Guards 简单来说,就是为Page实现一个RAII来自动管理资源。因为在BufferPoolManager::Unpin中,每次调用这个函数,都会让对应的page的pin_count_ - 1,当这个值为0时,这个page就可以被回收,或者说被替换了。但如果我们忘记去手动调用,该页面将永远不会被逐出缓冲池。由于缓冲池以更少的帧数运行,磁盘内外的页面交换将更多。不仅性能受到影响,而且很难检测到错误。\n主要需要考虑如何编写移动构造、移动赋值的逻辑。移动了一个对象后,原来的对象的资源应该转移到了新对象上,那么原来的对象无法再访问资源(将原来对象的资源重置nullptr或清空)。\n还有一个Drop()的接口,这是提供给使用者的释放资源的api,在实现虚构函数时可以直接调用它。Drop的实现就是调用Unpin,然后置空资源。需要注意的是,在进行移动赋值时,一开始也要Drop一下,考虑这样一种情况:\nauto p = std::move(basic_page_guard); p = std::move(basic_page_guard2); 这个时候同一个变量p接管了两个page,那么应该在第二个移动赋值时先drop掉第一个,因为第一个page不再使用了,自然要Unpin。\n还有就是在三个page guard类重载移动赋值时,如果需要移动的对象和自身是同一个,那么直接返回自己就好:\nauto BasicPageGuard::operator=(BasicPageGuard \u0026amp;\u0026amp;that) noexcept -\u0026gt; BasicPageGuard \u0026amp; { if (\u0026amp;that == this) { return *this; } // ... 其他操作 } 在ReadPageGuard和WritePageGuard的Drop()中,还需要考虑释放管理的page的锁。对应的,锁的获取发生在FetchPageWrite()和FetchPageWrite()中。\nTask2 - Extendible Hash Table Pages 为什么我们需要可扩展哈希?\n下图来源:https://www.bilibili.com/video/BV1Qt421w7JT\n在bustub的设计中,Header Page,Directory Page和Bucket Page都是无法直接构造出来的,即不能通过构造函数创建,只能通过各自的PageGuard中的As()或者AsMut()函数来转换。\nHeader header page中有一个max_depth_的成员变量,1 \u0026lt;\u0026lt; max_depth_即为header page中能存放的目录的索引的数量。当我们有值需要放入哈希表时,获取hash(key)的二进制最高max_depth_位作为索引,再从header中对应位置去找到directory。对应的ExtendibleHTableHeaderPage中的功能实现并不难。\nDirectory directory中有两个depth:\nGlobal Depth:若global depth为n,那么这个Directory就有2^n个entry(相当于指向2^n个bucket) Local Depth:若local depth为n,则在这个对应的bucket下,每个元素的key的最后n位都相同 类似header中获取下一级页的索引,directory获取hash(key)的二进制最低global_depth_位作为索引。那local depth的作用是什么呢?","title":"【CMU15-445 Fall2023】Project2 Extendible Hash Index 小结"},{"content":"为什么我们需要copy on write 通过xv6的实验指导书,我们可以知道:\nxv6中的fork()系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程所使用的页数很大,复制可能需要很长时间,而这样的复制操作经常是没有用的:fork()之后通常是子进程中的exec(),这会丢弃复制的内存而不是使用它们。\n那么,我们是不是可以在子进程刚创建时将其页表的映射到父进程的物理页,而在其需要对内存进行写操作时再重新分配内存呢?没错,这就是通过copy on write(写时复制)技术来进行优化。\n该实验的代码实现见:仓库commit\n大致思路 首先说一下大致思路:\n在使用fork系统调用创建子进程时,我们不用去额外拷贝父进程的内存,而是将子进程的虚拟内存映射到和父进程相同的物理内存,并且此时应该将父子进程对这块内存的访问权限设置为只读,并且添加一个用于识别COW(copy on write)的标志:\n当子进程需要对内存进行写操作时,RISC-V会检测到这一块物理地址的权限为只写,触发Page Fault,这时我们可以为子进程重新分配一块内存空间(取消之前的映射,分配内存后重新映射至新内存),并将父子进程的对应物理页的识别标志位进行修改,去除cow标志PTE_COW,添加写权限标志PTE_W:\n具体实现 COW标志位 首先我们需要为PTE添加一个用于识别COW的标志位,上图展示了PTE的后10位也就是其flags的情况,可以看到第8、9位是保留没有被使用的,那么我们可以用第8位来作为COW的标志位:\n// riscv.h #define PTE_COW (1L \u0026lt;\u0026lt; 8) 修改uvmcopy 在xv6的fork系统调用中,子进程拷贝父进程的物理页使用了uvmcopy()函数:\nint fork(void) { int i, pid; struct proc *np; struct proc *p = myproc(); // Allocate process. if((np = allocproc()) == 0){ return -1; } // Copy user memory from parent to child. if(uvmcopy(p-\u0026gt;pagetable, np-\u0026gt;pagetable, p-\u0026gt;sz) \u0026lt; 0){ freeproc(np); release(\u0026amp;np-\u0026gt;lock); return -1; } ... } 那么我们需要修改uvmcopy中的代码,将原先的拷贝内存的操作去掉,并将PTE的只读标志位去除、添加cow标记位:\nint uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; for(i = 0; i \u0026lt; sz; i += PGSIZE){ if((pte = walk(old, i, 0)) == 0) panic(\u0026#34;uvmcopy: pte should exist\u0026#34;); if((*pte \u0026amp; PTE_V) == 0) panic(\u0026#34;uvmcopy: page not present\u0026#34;); pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); // 这里移除了拷贝的代码,并设置了父进程相应的标志位 if (flags \u0026amp; PTE_W) { *pte = (*pte \u0026amp; ~PTE_W) | PTE_COW; flags = (flags \u0026amp; ~PTE_W) | PTE_COW; } // 将子进程的虚拟页映射至父进程的物理页,同时设置了子进程相应的标志位 if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){ goto err; } // 这里是对物理页的引用计数进行+1,后文会说明 kref_inc((void*)pa); } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; } 引用计数 Ensure that each physical page is freed when the last PTE reference to it goes away \u0026ndash; but not before. A good way to do this is to keep, for each physical page, a \u0026ldquo;reference count\u0026rdquo; of the number of user page tables that refer to that page. Set a page\u0026rsquo;s reference count to one when kalloc() allocates it. Increment a page\u0026rsquo;s reference count when fork causes a child to share the page, and decrement a page\u0026rsquo;s count each time any process drops the page from its page table. kfree() should only place a page back on the free list if its reference count is zero. It\u0026rsquo;s OK to to keep these counts in a fixed-size array of integers.\n当我们采取了cow后,只有当所有虚拟页都没有引用某一物理页时这个物理页才能被释放,那么我们可以使用一个数组来对每一个物理页进行引用计数,当fork导致子进程共享物理页时,对应的物理页的引用计数+1,当有进程不再使用物理页时,对应的物理页的引用计数-1,回收物理页的kfree()函数只有当物理页的引用计数为0时才会将其放回空闲列表。\n// kalloc.c struct { struct spinlock lock; // 保证操作的原子性 int ref_count[(PGROUNDUP(PHYSTOP)) / PGSIZE]; // KERNBASE~PHYSTOP是物理内存的大小,因为xv6的内核地址采用了直接映射,为了方便,这里直接使用PHYSTOP。PHYSTOP / PGSIZE则表示有多少个物理页 } kref; 内核在使用kinit()进行初始化时,需要初始化kref的锁,并设置引用计数数组的值:\nvoid kinit() { initlock(\u0026amp;kmem.lock, \u0026#34;kmem\u0026#34;); initlock(\u0026amp;kmem.lock, \u0026#34;kref\u0026#34;); // 这里初始化时置为1是为了接下来的freerange在调用kfree时不会触发panic for (int i = 0; i \u0026lt; PGROUNDUP(PHYSTOP) / PGSIZE; i++) { kref.ref_count[i] = 1; } freerange(end, (void*)PHYSTOP); } 在kfree()中对物理页的引用计数来判断是否应该释放物理内存:\nvoid kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa \u0026lt; end || (uint64)pa \u0026gt;= PHYSTOP) panic(\u0026#34;kfree\u0026#34;); // 如果在还未对ref count操作前其值已经小于等于0,说明已有问题 if (kref.ref_count[(uint64)pa / PGSIZE] \u0026lt;= 0) { panic(\u0026#34;kref\u0026#34;); } // 每次free一个page时,先将这个page的引用计数-1 kref_dec(pa); // ref count - 1,如果结果还大于0,说明这个物理页还被其他进程引用,暂时不需要释放 if (kref.ref_count[(uint64)pa / PGSIZE] \u0026gt; 0) { return; } // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); r = (struct run*)pa; acquire(\u0026amp;kmem.lock); r-\u0026gt;next = kmem.freelist; kmem.freelist = r; release(\u0026amp;kmem.lock); } 每分配一个物理页时,该物理页的引用计数初始为1:\nvoid * kalloc(void) { struct run *r; acquire(\u0026amp;kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r-\u0026gt;next; release(\u0026amp;kmem.lock); if (r) { memset((char*)r, 5, PGSIZE); // fill with junk // 这里赋值为1一定要在设置垃圾数值之后,否则会造成污染 acquire(\u0026amp;kref.lock); kref.ref_count[(uint64)r / PGSIZE] = 1; release(\u0026amp;kref.lock); } return (void*)r; } 将引用计数的操作封装一下:\n// kalloc.c // 为pa所在的page的引用+1 void kref_inc(void* pa) { acquire(\u0026amp;kref.lock); ++kref.ref_count[(uint64)pa / PGSIZE]; release(\u0026amp;kref.lock); } // 为pa所在的page的引用-1 void kref_dec(void* pa) { acquire(\u0026amp;kref.lock); --kref.ref_count[(uint64)pa / PGSIZE]; release(\u0026amp;kref.lock); } page fault时分配新内存 当需要分配新内存时,要检测虚拟页的cow标志位是否有效。在分配了内存后,拷贝原来的物理页中的数据到新的物理页,并将虚拟页的标志位中的cow去除、添加写标志,最后取消虚拟页与原来物理页的映射关系,同时在解除时对引用计数-1,然后将虚拟页映射至新的物理页:\n// 当出现page fault时,进行cow操作 int cow_alloc(pagetable_t pagetable, uint64 va) { if (va \u0026gt;= MAXVA) { return -1; } if ((va % PGSIZE) != 0) { return -1; } pte_t *pte = walk(pagetable, va, 0); if (pte == 0) return -1; uint64 pa = PTE2PA(*pte); if (pa == 0) return -1; // 当page的cow标志位有效时才会重新分配内存 if ((*pte \u0026amp; PTE_COW) \u0026amp;\u0026amp; (*pte \u0026amp; PTE_V)) { char* mem = kalloc(); if (mem == 0) { return -1; } uint64 flags = PTE_FLAGS(*pte); flags = (flags \u0026amp; ~PTE_COW) | PTE_W; // 去除COW标记,加上写权限标记 memmove(mem, (char *)pa, PGSIZE); uvmunmap(pagetable, PGROUNDDOWN(va), 1, 1); // 解除之前的映射,并设置do_free为1,这样在kfree中可来将引用计数-1 if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) \u0026lt; 0) { panic(\u0026#34;cow_alloc\u0026#34;); } } return 0; } 实验指导书提醒我们在copyout()中也要执行cow操作,但这里为何要这么做呢?\n这是因为需要内核将数据通过copyout拷贝到用户态时,如果需要拷贝的目标位置是用户进程与其父进程共享的,那么这时应该会有page fault产生,但是copyout中是通过walk遍历页表来获取地址的,不会触发page fault,因此需要我们手动执行cow。\nint copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; pte_t *pte; while(len \u0026gt; 0){ va0 = PGROUNDDOWN(dstva); if(va0 \u0026gt;= MAXVA) return -1; if (cow_alloc(pagetable, va0) \u0026lt; 0) { return -1; } ... } } usertrap中触发page fault 在之前fork操作时我们去除了物理页的write操作,那么在之后需要对该物理页进行写操作时,risc-v就会触发page fault了。\n查看risc-v的手册可以发现:\n我们需要使用excepton code 13和15(读写),当发生page fault时,这个code会保存在scause寄存器中,那么我们只需要在usertrap中对scause进行相应的处理即可:\nvoid usertrap(void) { ... } else if (r_scause() == 13 || r_scause() == 15) { // 这里的stval寄存器,我的理解是保存了触发page fault时的虚拟地址 uint64 fault_va = r_stval(); // 判断地址是否不合法 if (fault_va \u0026gt;= MAXVA || (fault_va \u0026lt; p-\u0026gt;trapframe-\u0026gt;sp \u0026amp;\u0026amp; fault_va \u0026gt;= (p-\u0026gt;trapframe-\u0026gt;sp - PGSIZE)) || fault_va \u0026lt;= 0) { p-\u0026gt;killed = 1; } // 尝试进行cow操作 if (cow_alloc(p-\u0026gt;pagetable, PGROUNDDOWN(fault_va)) \u0026lt; 0) { p-\u0026gt;killed = 1; } } else { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } } 值得注意的是,在进行cow操作之前,一定要对地址的范围进行合法性判断,因为usertests中会有非常多的测试函数,其中有一部分会检测地址合法性,不合法的地址应该直接让进程死亡。\n","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab5-copy-on-write-fork/","summary":"\u003ch2 id=\"为什么我们需要copy-on-write\"\u003e为什么我们需要copy on write\u003c/h2\u003e\n\u003cp\u003e通过xv6的实验指导书,我们可以知道:\u003c/p\u003e\n\u003cp\u003exv6中的\u003ccode\u003efork()\u003c/code\u003e系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程所使用的页数很大,复制可能需要很长时间,而这样的复制操作经常是没有用的:\u003ccode\u003efork()\u003c/code\u003e之后通常是子进程中的\u003ccode\u003eexec()\u003c/code\u003e,这会丢弃复制的内存而不是使用它们。\u003c/p\u003e\n\u003cp\u003e那么,我们是不是可以在子进程刚创建时将其页表的映射到父进程的物理页,而在其需要对内存进行写操作时再重新分配内存呢?没错,这就是通过copy on write(写时复制)技术来进行优化。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e该实验的代码实现见:\u003ca href=\"https://github.com/kerolt/xv6-labs-2023/commit/68fd833dc03a681c04a647ed6aae2108a1e43fbb\"\u003e仓库commit\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"【MIT6.S081】Lab5 copy-on-write fork"},{"content":"backtrace这个lab非常有意思,虽然实现的代码量不多,但是能让我们更好地理解栈、栈帧、指针、gdb的一些知识。\n该实验的代码实现见:仓库commit\n首先先解答一下【RISC-V assembly】中的一些问题:\nQ: Which registers contain arguments to functions? For example, which register holds 13 in main\u0026#39;s call to printf? A: a0-a7, a2保存了13 Q: Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.) A: 函数f和g被内联优化了 Q: At what address is the function printf located? A: 0x000000000000064a Q: What value is in the register ra just after the jalr to printf in main? A: jalr指令的后一条指令的地址,也是当前pc寄存器中的地址 Q: Run the following code. unsigned int i = 0x00646c72; printf(\u0026#34;H%x Wo%s\u0026#34;, 57616, \u0026amp;i); What is the output? Here\u0026#39;s an ASCII table that maps bytes to characters. The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value? Here\u0026#39;s a description of little- and big-endian and a more whimsical description. --- A: output: He110 World 若risc-v为大端序,则i应该设置成0x726c6400;57616不需要变,因为无论是大端序还是小端序,其十六进制都为E110 Q: In the following code, what is going to be printed after \u0026#39;y=\u0026#39;? (note: the answer is not a specific value.) Why does this happen? printf(\u0026#34;x=%d y=%d\u0026#34;, 3); --- A: x=3 y=1403684968 y的值是一个随机值,因为本该传入printf的第三个参数并没有传入,而其对应的寄存器为a2,故y会使用a2中残存的值 这里的几个问题不是很难,涉及到了一些汇编、寄存器的知识,在接下来学习backtrace的时候将会有详细讨论。\nbacktrace需要我们做的事情可以概括为:在发生错误的点之上的堆栈上的函数调用列表,并在每个堆栈帧中打印保存的返回地址。\n什么意思呢?就是例如在gdb调试中使用bt查看函数调用栈时,需要我们打印途中红色框中的地址。\n该lab让我们在kernel/printf.c中实现一个backtrace函数。在sys_sleep中插入对此函数的调用,然后运行bttest这个测试程序将调用sleep(也就是会执行sys_sleep)。\n首先,让我们来看看xv6中栈的结构:\n. . +-\u0026gt; . | +-----------------+ | | | return address | | | | previous fp ------+ | | saved registers | | | local variables | | | ... | \u0026lt;-+ | +-----------------+ | | | return address | | +------ previous fp | | | saved registers | | | local variables | | +-\u0026gt; | ... | | | +-----------------+ | | | return address | | | | previous fp ------+ | | saved registers | | | local variables | | | ... | \u0026lt;-+ | +-----------------+ | | | return address | | +------ previous fp | | | saved registers | | | local variables | | $fp --\u0026gt; | ... | | +-----------------+ | | return address | | | previous fp ------+ | saved registers | $sp --\u0026gt; | local variables | +-----------------+ 栈是由高地址向低地址增长的,risc-v中sp寄存器代表“stack pointer”,即栈顶指针,fp寄存器代表“frame pointer”,为当前栈帧的指针。\n假设有一个这样的程序:\n#include \u0026lt;stdio.h\u0026gt; void g() { printf(\u0026#34;g()\\n\u0026#34;); } void f() { g(); printf(\u0026#34;f()\\n\u0026#34;); } int main() { f(); } 假设程序中的main函数里,函数f调用了函数g,那么在函数调用栈中从高地址到低地址三个函数的顺序为:main、f、g;当g函数执行完成后,其栈帧将会从栈中弹出,并且通过栈帧中的数据回到调用自身的下一条指令,即f中g的调用发生在第8行,当g执行完毕后,应该继续执行第9行的指令,这也就是“return address”。\n我们可以通过内联汇编获取当前栈帧的的指针:\n// kernel/riscv.h static inline uint64 r_fp() { uint64 x; asm volatile(\u0026#34;mv %0, s0\u0026#34; : \u0026#34;=r\u0026#34; (x) ); return x; } 然后不断遍历栈中的栈帧,打印其return address,直到遍历到最后一个栈帧。xv6在给栈分配内存时确保了每一个栈帧都在同一页中,这样的话可以通过PGROUNDDOWN(fp)宏来判断fp是否超出栈空间:\n#define PGROUNDDOWN(a) (((a)) \u0026amp; ~(PGSIZE - 1)) xv6中页的大小为4096B,故PGROUNDDOWN(a)可以获取a地址所在的页号,或者说这一页的最高地址,只要我们的fp不等于它,就说明我们还没有遍历到栈底。\nfp - 8获取到return address的地址,fp - 16获取到当前栈帧的前一个栈帧的地址。由于xv6中获取的地址是用uint64来表示的,那么可将其强转为uint64*来将一个值解释为内存地址,之后便可以解引用这个地址获取其中的值了。\nvoid backtrace() { uint64 fp = r_fp(); printf(\u0026#34;backtrace:\\n\u0026#34;); while (fp != PGROUNDDOWN(fp)) { uint64 *return_addr = (uint64 *)(fp - 8); fp = *(uint64 *)(fp - 16); printf(\u0026#34;%p\\n\u0026#34;, *return_addr); } } 当然,也不要忘记在kernel/def.h中声明backtrace,还有在sys_sleep中调用backtrace。\n","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab4-trap-backtrace/","summary":"\u003cp\u003ebacktrace这个lab非常有意思,虽然实现的代码量不多,但是能让我们更好地理解栈、栈帧、指针、gdb的一些知识。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e该实验的代码实现见:\u003ca href=\"https://github.com/kerolt/xv6-labs-2023/commit/d1dba8ae4a1a71604e8bb6df5238f8cac1771683\"\u003e仓库commit\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"【MIT6.S081】Lab4 trap backtrace"},{"content":"Alarm综合了该lab中前几个练习的知识点:系统调用、中断、寄存器等,我们需要对trap机制有比较好的认识才能理解。Alarm的任务是需要我们完成一个定时器的实现:sigalarm(interval, handler),当调用sigalarm(n, fn)时,内核会每n个时间间隔(tick)执行fn函数。\n该实验的代码实现见:仓库commit\n如何理解Alarm “内核会每n个时间间隔执行fn函数”,如何理解这“每n个时间间隔”呢?在计算机中有一个“时钟周期”的概念,而我们这里所说的时间间隔就是xv6中设置的每次发生时钟中断所间隔的始终周期。在xv6内核初始化时会执行timerinit()函数,其中有:\n// ask the CLINT for a timer interrupt. int interval = 1000000; // cycles; about 1/10th second in qemu. *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval; 这里就是设置了xv6会每经过interval个时钟周期(在qemu中大概为0.1秒)进行一次时钟中断,这个中断是硬件自动执行的(在没看源码之前,一直没搞懂xv6是什么时候、怎么进行的时钟中断)。下文将以时钟中断的间隔tick作为基本单位。\n所以简单来说,Alarm需要我们在内核中进行计数,每当经过n个tick的时候,需要去执行fn函数。\ntest0~3所做的事 test0:用于测试我们的sigalarm是否起作用了; test1:用于测试内核是否多次调用处理函数,需要确保中断发生时跳转的地址为处理函数所在的地址,还有中断时需要保存好之前寄存器中的值; test2:用于测试内核不允许重入sysalarm系统调用,即若某个进程正在执行处理函数,那么内核就不应该再次调用它; test3:用于测试sys_sigreturn系统调用能否正确返回寄存器a0的值。 实现 注册系统调用 首先的注册系统调用的步骤这里就不展开了,具体可参考之前的lab。\nstruc proc结构体 为了完成定时执行某个函数,我们需要在struct proc结构体中加入一些成员:\nstruct proc { ... int is_handling; // 用于判断当前进程是否正在执行处理函数 int tick_interval; // 定时器间隔,由系统sigalarm的第一个参数传入 int tick_counter; // 定时器计数器,每次tick进行+1 uint64 tick_handler; // 间隔到了后执行的处理函数 struct trapframe *saved_trapframe; // 保存寄存器 } 值的注意的是处理函数的类型我们设置为了uint64,为什么不是一个函数指针呢?其实都差不多,在之后设置跳转处理函数的时候,就是通过地址来跳转,而地址在xv6中就是用的uint64来表示。故这里的设置即是处理函数所在的起始地址。\n实现sys_sigalarm 在sysproc.c中实现sys_sigalarm()函数:\nuint64 sys_sigalarm(void) { struct proc *p = myproc(); argint(0, \u0026amp;(p-\u0026gt;tick_interval)); argaddr(1, \u0026amp;(p-\u0026gt;tick_handler)); return 0; } 该函数要做的事情很简单,只需要接收从用户态传来的两个参数,并将其赋值给当前进程的tick_interval和tick_handler。\n进程初始化与结束销毁 接着需要在进程创建和销毁时对这些变量进行相应的初始化和清零:\nstatic struct proc* allocproc(void) { ... p-\u0026gt;tick_interval = 0; p-\u0026gt;tick_counter = 0; p-\u0026gt;tick_handler = 0; p-\u0026gt;is_handling = 0; ... // Allocate a saved_trapframe page. if((p-\u0026gt;saved_trapframe = (struct trapframe *)kalloc()) == 0){ freeproc(p); release(\u0026amp;p-\u0026gt;lock); return 0; } ... } static void freeproc(struct proc *p) { ... if(p-\u0026gt;saved_trapframe) kfree((void*)p-\u0026gt;saved_trapframe); p-\u0026gt;saved_trapframe = 0; ... p-\u0026gt;tick_counter = 0; p-\u0026gt;tick_interval = 0; p-\u0026gt;tick_handler = 0; p-\u0026gt;is_handling = 0; } 补全usertrap 接下来就是需要实现在usertrap中处理时钟中断,在实验指导书中提示我们在if(which_dev == 2) ...中处理时钟中断。这里我的处理逻辑为,当时钟中断发生时:\n当前进程的tick计数器++ 判断进程设置的定时器间隔是否不为0、当前计数器是否已经经过了interval个间隔、且当前进程未执行处理函数,如果其中一项不满足,则不进行第三步 将当前进程的trapframe的内容(即寄存器的值)保存到saved_trapframe(用于恢复现场),将SEPC寄存器的值设置为处理函数的地址,这样中断结束返回时就会去执行处理函数了,最后设置当前进程“正在执行处理函数” 代码如下:\nvoid usertrap(void) { ... // give up the CPU if this is a timer interrupt. if (which_dev == 2) { p-\u0026gt;tick_counter++; if (p-\u0026gt;tick_interval \u0026amp;\u0026amp; p-\u0026gt;tick_counter % p-\u0026gt;tick_interval == 0 \u0026amp;\u0026amp; p-\u0026gt;is_handling == 0) { memmove(p-\u0026gt;saved_trapframe, p-\u0026gt;trapframe, PGSIZE); p-\u0026gt;trapframe-\u0026gt;epc = p-\u0026gt;tick_handler; p-\u0026gt;is_handling = 1; } yield(); } ... 在第二步中不知道是xv6的bug还是我有地方没理解好,如果不先检查tick_interval != 0,则可能在执行p-\u0026gt;tick_counter % p-\u0026gt;tick_interval会有问题,因为取模运算符%在分母为0时是未定义的,但这么运行时xv6并没有任何错误。\n返回,恢复现场 在处理函数执行的最后,将会执行sigreturn系统调用进行返回并恢复现场,这时我们就可以将之前存放在saved_trapframe中的值拷贝回trapframe中,并设置当前进程“未执行处理函数”。实验指导书中还提示我们最终返回的结果为a0寄存器中的值。\nuint64 sys_sigreturn(void) { struct proc *p = myproc(); memmove(p-\u0026gt;trapframe, p-\u0026gt;saved_trapframe, PGSIZE); p-\u0026gt;is_handling = 0; return p-\u0026gt;trapframe-\u0026gt;a0; // return this for alarm test3 } ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab4-trap-alarm/","summary":"\u003cp\u003eAlarm综合了该lab中前几个练习的知识点:系统调用、中断、寄存器等,我们需要对trap机制有比较好的认识才能理解。Alarm的任务是需要我们完成一个定时器的实现:\u003ccode\u003esigalarm(interval, handler)\u003c/code\u003e,当调用\u003ccode\u003esigalarm(n, fn)\u003c/code\u003e时,内核会每n个时间间隔(tick)执行fn函数。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e该实验的代码实现见:\u003ca href=\"https://github.com/kerolt/xv6-labs-2023/commit/0f70ef7d68ab1273262b8d43a2deaf7345a87698\"\u003e仓库commit\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"【MIT6.S081】Lab4 trap alarm"},{"content":"前言 页表是最常用的机制,操作系统通过它为每个进程提供自己的私有地址空间和内存。页表决定了内存地址的含义,以及可以访问物理内存的哪些部分。在本文中,记录了Lab: page tables的前两个实验:加速系统调用和打印页表。\nSpeed up system calls (easy) 通常我们在需要执行系统调用时,在操作系统中会发生从用户态到内核态的切换,这是因为这些核心的操作只能交给内核去完成。在这个实验中,xv6要求我们通过在用户空间和内核之间共享只读区域中的数据来加快某些系统调用。\n由于现在只是入门如何将映射添加至页表中,这个实验只需要为xv6中的getpid()系统调用进行优化。\n在xv6的实验指导书中:\n创建每个进程时,在USYSCALL(memlayout.h中定义的虚拟地址)映射一个只读页。在该页面的开头,存储一个usyscall结构体(也在memlayout.h中定义),并对其进行初始化以存储当前进程的PID。\n既然如此,进程结构体中也应当有一个usyscall结构体:\n// kernel/proc.h struct proc { ... pagetable_t pagetable; // User page table struct trapframe *trapframe; // data page for trampoline.S struct usyscall *usyscall; // data page for USYSCALL struct context context; // swtch() here to run process ... }; 这样就可以通过p-\u0026gt;usyscall来获取了。\n在memlayout.h中我们可以看到用户态空间内存布局:\nAddress zero first: text original data and bss fixed-size stack expandable heap ... USYSCALL (shared with kernel) TRAPFRAME (p-\u0026gt;trapframe, used by the trampoline) TRAMPOLINE (the same page as in the kernel) MAXVA-\u0026gt; ------------------------------------- | TRAMPOLINE (与内核相同的页面) | ------------------------------------- | TRAPFRAME (p-\u0026gt;trapframe, 由跳板使用)| ------------------------------------- | USYSCALL (与内核共享) | ------------------------------------- | ... | ------------------------------------- | 可扩展堆 | ------------------------------------- | 固定大小的栈 | ------------------------------------- | 原始数据和BSS | ------------------------------------- | text | 0 -\u0026gt; ------------------------------------- 我们需要做的就是仿照TRAPFRAME将USYSCALL也做一层映射。\n在allocproc()为进程分配物理页时,使用kalloc()对usyscall的分配(kalloc每次从空闲页表中取出一个项,其大小为4KB):\n// Allocate a usyscall page. if((p-\u0026gt;usyscall = (struct usyscall *)kalloc()) == 0){ freeproc(p); release(\u0026amp;p-\u0026gt;lock); return 0; } 在proc_pagetable()函数中,其为指定进程创建用户页表,不含用户内存,但有trampoline 和 trapframe页。以下代码是对trampoline 和 trapframe进行映射。\n// map the trampoline code (for system call return) // at the highest user virtual address. // only the supervisor uses it, on the way // to/from user space, so not PTE_U. if(mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) \u0026lt; 0){ uvmfree(pagetable, 0); return 0; } // map the trapframe page just below the trampoline page, for // trampoline.S. if(mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p-\u0026gt;trapframe), PTE_R | PTE_W) \u0026lt; 0){ uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmfree(pagetable, 0); return 0; } 如此,我们照猫画虎,也可以写出usyscall的映射。需要注意的是,该页是read-only的,并且允许用户态访问,因此其权限应该为PTE_R和PTE_U。\n// map the usyscall page if(mappages(pagetable, USYSCALL, PGSIZE, (uint64)(p-\u0026gt;usyscall), PTE_R | PTE_U) \u0026lt; 0){ uvmunmap(pagetable, USYSCALL, 1, 0); uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmfree(pagetable, 0); return 0; } 在结束进程时,即freeproc函数中,也需要对usyscall的空间进行释放:\nif(p-\u0026gt;usyscall) kfree((void*)p-\u0026gt;usyscall); p-\u0026gt;usyscall = 0; 同时还应当在proc_freepagetable函数中解除之前对usyscall的映射:\nuvmunmap(pagetable, USYSCALL, 1, 0); Print a page table (easy) 这个实验要求我们将页表打印出来。在实验开始前,让我们先看看xv6中的页表。\nxv6中的页表为三级页表,在VA转换为PA的过程中,处理单元会通过satp寄存器找到当前进程的页表基地址,然后取出VA中的L2部分找到一级页表的项,一级页表中的项(PTE)保存二级页表的地址,再通过L1可获取二级页表中的项,依次类推即可将VA转换为PA。\n这样看来,想要打印页表,有点类似于DFS算法,需要使用递归。按照实验指导书所说,我们可以从freewalk函数中获取灵感,查看其源码可以知道如何去遍历页表项。那么按照要求所实现打印页表就比较容易了:\nstatic void print_pgtbl(pagetable_t pagetable, int depth) { if (depth \u0026gt; 2) { return; } for(int i = 0; i \u0026lt; 512; i++){ pte_t pte = pagetable[i]; if (pte \u0026amp; PTE_V) { if (depth == 0) { printf(\u0026#34;..\u0026#34;); } else if (depth == 1) { printf(\u0026#34;.. ..\u0026#34;); } else if (depth == 2) { printf(\u0026#34;.. .. ..\u0026#34;); } uint64 child = PTE2PA(pte); printf(\u0026#34;%d: pte %p pa %p\\n\u0026#34;, i, pte, PTE2PA(pte)); print_pgtbl((pagetable_t)child, depth + 1); } } } void vmprint(pagetable_t pagetable) { printf(\u0026#34;page table %p\\n\u0026#34;, pagetable); print_pgtbl(pagetable, 0); } 由于打印页表这个操作是进程号为1的init进程做的,所以不要忘记在kernel/exec.c的exec函数中添加:\nif (p-\u0026gt;pid == 1) { vmprint(p-\u0026gt;pagetable); } 并且在kernel/defs.h中添加vmprint的函数声明:\nvoid vmprint(pagetable_t); ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab3-page-tables%E4%B8%8A/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e页表是最常用的机制,操作系统通过它为每个进程提供自己的私有地址空间和内存。页表决定了内存地址的含义,以及可以访问物理内存的哪些部分。在本文中,记录了\u003ccode\u003eLab: page tables\u003c/code\u003e的前两个实验:加速系统调用和打印页表。\u003c/p\u003e","title":"【MIT6.S081】Lab3 page tables(上)"},{"content":"本关的任务为“Detect which pages have been accessed”,需要实现一个新的系统调用pgaccess,它指出访问了哪些页面被访问了(读、写等)。系统调用需要三个参数:\n第一个用户页面的起始虚拟地址 需要检查页数 一个存储每一页是否被访问的掩码 该lab的所有代码:Github\n测试该系统调用的函数位于user/pgtbltest.c:pgaccess_test()中:\nvoid pgaccess_test() { char *buf; unsigned int abits; printf(\u0026#34;pgaccess_test starting\\n\u0026#34;); testname = \u0026#34;pgaccess_test\u0026#34;; buf = malloc(32 * PGSIZE); if (pgaccess(buf, 32, \u0026amp;abits) \u0026lt; 0) err(\u0026#34;pgaccess failed\u0026#34;); buf[PGSIZE * 1] += 1; buf[PGSIZE * 2] += 1; buf[PGSIZE * 30] += 1; if (pgaccess(buf, 32, \u0026amp;abits) \u0026lt; 0) err(\u0026#34;pgaccess failed\u0026#34;); if (abits != ((1 \u0026lt;\u0026lt; 1) | (1 \u0026lt;\u0026lt; 2) | (1 \u0026lt;\u0026lt; 30))) err(\u0026#34;incorrect access bits set\u0026#34;); free(buf); printf(\u0026#34;pgaccess_test: OK\\n\u0026#34;); } 分析下源码可以知道,测试程序分配了32个页,并且使用(or 访问)了这分配的32个页的第1、2、30页,之后程序调用pgaccess来检测abits的第1、2、30位是否为1,即判断该系统调用是否实现了“检测已经访问的页”这个功能。\n向内核添加系统调用的方法在lab2中已经了解过了,不过这里xv6已经帮我们添加好了,我们只需要实现系统调用kernel/sysproc.c:sys_pgaccess()即可。\n在xv6 book中可以知道一个PTE的每位构成如上图,其中0 - 9位是一些标志位,第6位为Accessed,也就是访问位,需要在内核中添加这个标志:\n// kernel/riscv.h #define PTE_A (1L \u0026lt;\u0026lt; 6) 而risc-v处理器会利用硬件将已访问的页的PTE_A正确设置。\n实现sys_pgaccess的步骤大致可分为:\n接受用户态传递的三个参数: 第一个用户页面的起始虚拟地址(指针) 需要检查页数(int) 一个存储每一页是否被访问的掩码(指针) 遍历“需要检查页数”,并每次检查遍历的页是否已访问。获取每个页对应的PTE将使用walk函数来获取,而检查将使用PTE_A来判断;如果当前页的PTE_A为1,则说明该页被访问过,利用位运算(是的,位运算在该lab里立大功)来存储信息,即第几页被访问了,并且不要忘记了实验指导中的提示,“Be sure to clear PTE_A after checking if it is set. Otherwise, it won\u0026rsquo;t be possible to determine if the page was accessed since the last time pgaccess() was called”,检测完后应该将PTE_A标记位清除。 将存储的信息利用copyout函数从内核态传递给用户态,届时用户态可通过第三个参数获取。 具体实现如下:\nint sys_pgaccess(void) { // lab pgtbl: your code here. uint64 uvm_pgaddr; int page_counts; uint64 abits; argaddr(0, \u0026amp;uvm_pgaddr); argint(1, \u0026amp;page_counts); argaddr(2, \u0026amp;abits); int result = 0; pagetable_t page_table = myproc()-\u0026gt;pagetable; for (int i = 0; i \u0026lt; page_counts; i++) { pte_t *pte = walk(page_table, uvm_pgaddr, 0); if (((*pte) \u0026amp; (PTE_A)) != 0) { result |= (1 \u0026lt;\u0026lt; i); *pte \u0026amp;= (~PTE_A); } uvm_pgaddr += PGSIZE; } copyout(page_table, abits, (char*)\u0026amp;result, sizeof(result)); return 0; } ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab3-page-tables%E4%B8%8B/","summary":"\u003cp\u003e本关的任务为“Detect which pages have been accessed”,需要实现一个新的系统调用\u003ccode\u003epgaccess\u003c/code\u003e,它指出访问了哪些页面被访问了(读、写等)。系统调用需要三个参数:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e第一个用户页面的起始虚拟地址\u003c/li\u003e\n\u003cli\u003e需要检查页数\u003c/li\u003e\n\u003cli\u003e一个存储每一页是否被访问的掩码\u003c/li\u003e\n\u003c/ol\u003e\n\u003cblockquote\u003e\n\u003cp\u003e该lab的所有代码:\u003ca href=\"https://github.com/kerolt/xv6-labs-2023/commit/87373230877d27d7f60c39696688863210c41133\"\u003eGithub\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"【MIT6.S081】Lab3 page tables(下)"},{"content":"前言 这个lab开始我们就正式进入了xv6的世界了,这一次我们可以了解到内核中系统调用的注册和运行原理,这可以说是之后lab的一个基石。\ntrace 首先,我们要清楚这个实验的目的是什么:\nIn this assignment you will add a system call tracing feature that may help you when debugging later labs. You\u0026rsquo;ll create a new trace system call that will control tracing. It should take one argument, an integer \u0026ldquo;mask\u0026rdquo;, whose bits specify which system calls to trace. For example, to trace the fork system call, a program calls trace(1 \u0026laquo; SYS_fork), where SYS_fork is a syscall number from kernel/syscall.h. You have to modify the xv6 kernel to print out a line when each system call is about to return, if the system call\u0026rsquo;s number is set in the mask. The line should contain the process id, the name of the system call and the return value; you don\u0026rsquo;t need to print the system call arguments. The trace system call should enable tracing for the process that calls it and any children that it subsequently forks, but should not affect other processes.\n译:在Xv6的trace命令中,它应该有一个参数,一个整数“掩码”,其位指定要跟踪的系统调用。例如,要跟踪fork系统调用,程序调用trace(1\u0026lt;\u0026lt;SYS_fork),其中SYS_fork是kernel/syscall.h中的系统调用编号。如果系统调用的编号在掩码中设置,则必须修改xv6内核,以便在每个系统调用即将返回时打印出一行。该行应包含进程id、系统调用的名称和返回值;您不需要打印系统调用参数。跟踪系统调用应启用对调用它的进程及其随后分叉的任何子进程的跟踪,但不应影响其他进程。\n注意,在该实验的初始阶段,xv6已经为我们提供了trace命令的用户态实现,但是其底层的系统调用需要我们自己实现。\nmask是什么? 在使用trace命令时用到的掩码,是用来跟踪之后使用的命令用到了哪些系统调用。例如实验中给出的例子:\n$ trace 32 grep hello README 3: syscall read -\u0026gt; 1023 3: syscall read -\u0026gt; 966 3: syscall read -\u0026gt; 70 3: syscall read -\u0026gt; 0 这个32就是掩码,其跟踪到了grep命令中使用到了read系统调用(为什么是read?马上就说到了)。在xv6的kernel/syscall.h中有所有系统调用的编号:\n// System call numbers #define SYS_fork 1 #define SYS_exit 2 #define SYS_wait 3 #define SYS_pipe 4 #define SYS_read 5 #define SYS_kill 6 #define SYS_exec 7 #define SYS_fstat 8 #define SYS_chdir 9 #define SYS_dup 10 #define SYS_getpid 11 #define SYS_sbrk 12 #define SYS_sleep 13 #define SYS_uptime 14 #define SYS_open 15 #define SYS_write 16 #define SYS_mknod 17 #define SYS_unlink 18 #define SYS_link 19 #define SYS_mkdir 20 #define SYS_close 21 // 添加 #define SYS_trace 22 将这个mask以二进制的形式来看待更加容易理解,如果传入的mask是32,那么其二进制为100000,这个1出现的位置是第5位(最低位按0计数),也就是去找编号为5的系统调用,也就是SYS_read。\nxv6内核提供给用户态的接口为trace,但是我们需要自己在xv6的用户头文件中添加函数的声明:\n// user/user.h // ... int trace(int); // ... 这个trace底层其实调用的应该是sys_trace(这个函数名不是固定的,但是源码中其他的系统调用的命名都为sys_*,故trace对应的系统调用写成sys_trace更加合理)。sys_trace需要做的是将用户传入的mask再传给当前进程及其子进程。\n我们这里将系统调用sys_trace的编号设置为22。\n进程及其子进程如何获取mask? 在xv6 book的4.3节中,有这么一段话:\nsyscall (kernel/syscall.c:132) retrieves the system call number from the saved a7 in the trapframe and uses it to index into syscalls. For the first system call, a7 contains SYS_exec (ker\u0002nel/syscall.h:8), resulting in a call to the system call implementation function sys_exec. When sys_exec returns, syscall records its return value in p-\u0026gt;trapframe-\u0026gt;a0. This will cause the original user-space call to exec() to return that value, since the C calling convention on RISC-V places return values in a0. System calls conventionally return negative numbers to indicate errors, and zero or positive numbers for success. If the system call number is invalid, syscall prints an error and returns −1. 即系统调用的编号会保存在进程的trapframe中,根据a7寄存器即可获得,系统调用的返回值可通过a0寄存器获得。欸!这两个值可不就是实验实现中需要的吗!那么理所当然,实验中需要打印的语句应该就在这个函数中添加。\n让我们来看看xv6中进程的数据结构:\n// kernel/proc.h struct proc { struct spinlock lock; // p-\u0026gt;lock must be held when using these: enum procstate state; // Process state void *chan; // If non-zero, sleeping on chan int killed; // If non-zero, have been killed int xstate; // Exit status to be returned to parent\u0026#39;s wait int pid; // Process ID // wait_lock must be held when using this: struct proc *parent; // Parent process // these are private to the process, so p-\u0026gt;lock need not be held. uint64 kstack; // Virtual address of kernel stack uint64 sz; // Size of process memory (bytes) pagetable_t pagetable; // User page table struct trapframe *trapframe; // data page for trampoline.S struct context context; // swtch() here to run process struct file *ofile[NOFILE]; // Open files struct inode *cwd; // Current directory char name[16]; // Process name (debugging) // 添加 int trace_mask; } 可以看到诸如进程名、pid、上下文等信息都是保存在这个数据结构中,那么我们可以在其中加上一个成员变量 trace_mask 用于保存当前进程所对应trace命令中的掩码mask。\nxv6已经实现了用户态的trace命令,其位于 user/trace.c 中:\n#include \u0026#34;kernel/param.h\u0026#34; #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char *argv[]) { int i; char *nargv[MAXARG]; if(argc \u0026lt; 3 || (argv[1][0] \u0026lt; \u0026#39;0\u0026#39; || argv[1][0] \u0026gt; \u0026#39;9\u0026#39;)){ fprintf(2, \u0026#34;Usage: %s mask command\\n\u0026#34;, argv[0]); exit(1); } if (trace(atoi(argv[1])) \u0026lt; 0) { fprintf(2, \u0026#34;%s: trace failed\\n\u0026#34;, argv[0]); exit(1); } for(i = 2; i \u0026lt; argc \u0026amp;\u0026amp; i \u0026lt; MAXARG; i++){ nargv[i-2] = argv[i]; } exec(nargv[0], nargv); exit(0); } 对于这样的一条命令 trace 32 grep hello README ,假设开启的进程名为p,那么32将会传给p.trace_mask,之后的grep操作将使用exec创建子进程(假设进程名为son)执行,那么在创建子进程后应该有son.trace_mask = p.trace_mask,只有这样,grep操作所用到的系统调用才能被跟踪到。\n在使用trace命令时,其后的mask参数会存到a0寄存器中,为了从其中拿到mask,可以使用argint()函数,其源码为:\n// kernel/syscall.h // Fetch the nth 32-bit system call argument. void argint(int n, int *ip) { *ip = argraw(n); } static uint64 argraw(int n) { struct proc *p = myproc(); switch (n) { case 0: return p-\u0026gt;trapframe-\u0026gt;a0; case 1: return p-\u0026gt;trapframe-\u0026gt;a1; case 2: return p-\u0026gt;trapframe-\u0026gt;a2; case 3: return p-\u0026gt;trapframe-\u0026gt;a3; case 4: return p-\u0026gt;trapframe-\u0026gt;a4; case 5: return p-\u0026gt;trapframe-\u0026gt;a5; } panic(\u0026#34;argraw\u0026#34;); return -1; } argint()内调用了argraw(),在查看以上源码后,由于只传入了一个参数,故应该将0传入argraw中。在kernel/sysproc.c中实现sys_trace():\n// kernel/sysproc.c // ... uint64 sys_trace(void) { int mask; argint(0, \u0026amp;mask); if (mask \u0026lt; 0) return -1; struct proc *p = myproc(); p-\u0026gt;trace_mask = mask; return 0; } 这样当trace使用了底层的sys_trace时,就可以把mask参数传递给当前进程。但是只传递给当前进程还不够,还要传给当前进程的子进程。在Linux,我们创建子进程的函数为fork(),在xv6中也同样如此,fork内部先获取当前进程的proc结构体,然后新创建一个proc结构体代表子进程,并将父进程中的值拷贝过去,故传递给子进程的mask也在其中拷贝:\n// kernel/proc.c int fork(void) { // ... // copy mask from father process to son process // np为子进程,p为父进程 np-\u0026gt;trace_mask = p-\u0026gt;trace_mask; ... } 要注意一个小细节,当进程结构体被释放时(进程结束或者为进程分配proc结构体),其mask也该重置:\n// kernel/proc.c static void freeproc(struct proc *p) { // ... ... p-\u0026gt;trace_mask = 0; } 完成好以上内容后,就可以实现sys_trace了:\n// kernel/sysproc.c uint64 sys_trace(void) { int mask; argint(0, \u0026amp;mask); if (mask \u0026lt; 0) return -1; struct proc *p = myproc(); p-\u0026gt;trace_mask = mask; return 0; } 这样,当前进程就可以获取了到mask,当其创建子进程时,子进程也可获取到mask~\n如何跟踪系统调用? 刚刚我们说了mask的作用,还有进程及其子进程如何获取mask,那么我们又应该如何跟踪系统调用呢?\nxv6中所用的系统调用都是在 kernel/syscall.c 中的 syscall 函数中调用的,为了能在syscall.c中调用sys_trace,需要在其中添加extern声明(其定义在刚刚已经实现,位于kernel/sysproc.c):\nextern uint64 sys_trace(void); 同时需要在syscalls数组中添加sys_trace的编号:\nstatic uint64 (*syscalls[])(void) = { // ... ... [SYS_trace] sys_trace, }; // 这里实际上就是 [22] = sys_trace // 使用了gcc的一个拓展 并按照顺序添加各个系统调用的名字:\nchar *syscall_names[] = { \u0026#34;fork\u0026#34;, \u0026#34;exit\u0026#34;, \u0026#34;wait\u0026#34;, \u0026#34;pipe\u0026#34;, \u0026#34;read\u0026#34;, \u0026#34;kill\u0026#34;, \u0026#34;exec\u0026#34;, \u0026#34;fstat\u0026#34;, \u0026#34;chdir\u0026#34;, \u0026#34;dup\u0026#34;, \u0026#34;getpid\u0026#34;, \u0026#34;sbrk\u0026#34;, \u0026#34;sleep\u0026#34;, \u0026#34;uptime\u0026#34;, \u0026#34;open\u0026#34;, \u0026#34;write\u0026#34;, \u0026#34;mknod\u0026#34;, \u0026#34;unlink\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;mkdir\u0026#34;, \u0026#34;close\u0026#34;, \u0026#34;trace\u0026#34;, }; 在 kernel/syscall.c 中,xv6根据a7寄存器获取系统调用的编号,然后通过syscalls函数数组执行系统调用,那么我们的实现为:当使用的系统调用合法时,获取当前进程的mask,并通过判断(mask \u0026gt;\u0026gt; syscall_num) \u0026amp; 1是否为1来输出跟踪信息。\n例如sys_read系统调用的编号为5,mask为32,则(32 \u0026gt;\u0026gt; 5) \u0026amp; 1 = 1。\nvoid syscall(void) { int num; struct proc *p = myproc(); num = p-\u0026gt;trapframe-\u0026gt;a7; if(num \u0026gt; 0 \u0026amp;\u0026amp; num \u0026lt; NELEM(syscalls) \u0026amp;\u0026amp; syscalls[num]) { // Use num to lookup the system call function for num, call it, // and store its return value in p-\u0026gt;trapframe-\u0026gt;a0 p-\u0026gt;trapframe-\u0026gt;a0 = syscalls[num](); // =============================== int mask = p-\u0026gt;trace_mask; if ((mask \u0026gt;\u0026gt; num) \u0026amp; 1) { printf(\u0026#34;%d: syscall %s -\u0026gt; %d\\n\u0026#34;, p-\u0026gt;pid, syscall_names[num - 1], p-\u0026gt;trapframe-\u0026gt;a0); } // =============================== } else { printf(\u0026#34;%d %s: unknown sys call %d\\n\u0026#34;, p-\u0026gt;pid, p-\u0026gt;name, num); p-\u0026gt;trapframe-\u0026gt;a0 = -1; } } 那么,内核是如何通过trace找到sys_trace的呢?根据实验指导上的提示,可以知道 user/usys.pl 起到了一个中间人的作用:\n# user/usys.pl ... sub entry { my $name = shift; print \u0026#34;.global $name\\n\u0026#34;; print \u0026#34;${name}:\\n\u0026#34;; print \u0026#34; li a7, SYS_${name}\\n\u0026#34;; print \u0026#34; ecall\\n\u0026#34;; print \u0026#34; ret\\n\u0026#34;; } ... entry(\u0026#34;uptime\u0026#34;); ++entry(\u0026#34;trace\u0026#34;); # 这是实验中需要由我们自己添加的 通过其中的entry函数,可以生成对应的调用(xv6中为ecall)系统调用的汇编语句,即大致的流程为xv6在构建内核时,会将用户态trace命令对应到:\n.global trace trace: li a7, SYS_trace ecall ret 这样,当前进程就可以通过a7寄存器拿到sys_trace的系统调用编号了,也就是说,syscall 函数可以调用 sys_trace 了。\nOK,那么一个大致的框架就出来了:\n完成上面的步骤后,最后只要在Makefile中的UPROGS加上$U/_trace即可。\nUPROGS=\\ $U/_cat\\ $U/_echo\\ $U/_forktest\\ $U/_grep\\ $U/_init\\ $U/_kill\\ $U/_ln\\ $U/_ls\\ $U/_mkdir\\ $U/_rm\\ $U/_sh\\ $U/_stressfs\\ $U/_usertests\\ $U/_grind\\ $U/_wc\\ $U/_zombie\\ ++\t$U/_trace 在这个lab中,我们需要添加一个系统调用sysinfo,用于收集有关正在运行的系统的信息。\n系统调用的声明为:\nint sysinfo(struct sysinfo*); 这个系统调用接受一个指向结构体sysinfo的指针,其定义为:\n// kernel/sysinfo.h struct sysinfo { uint64 freemem; // amount of free memory (bytes) uint64 nproc; // number of process }; 内核应填写此结构的字段:freemem字段应设置为可用内存的字节数,nproc字段应设为状态未使用的进程数。\nsysinfo 在这个part中,我们需要添加一个系统调用sysinfo,用于收集有关正在运行的系统的信息。\n计算可用内存字节数 我们可以通过内核中的kmem来获取可用的内存块的数量:\nstruct { struct spinlock lock; struct run *freelist; } kmem; kmem.freelist是一个链表,保存了所有可用的内存块的地址,我们遍历这个链表即可获取可用内存块数量,又一个内存块的大小为4KB,那么系统可用的内存字节数 = 可用内存块数量 * 4KB:\n// kernel/kalloc.c uint64 freemem() { struct run* r; uint64 free_page = 0; acquire(\u0026amp;kmem.lock); r = kmem.freelist; while (r) { free_page++; r = r-\u0026gt;next; } release(\u0026amp;kmem.lock); // 4K = 2^12,左移操作相当于对2的乘法 return (free_page \u0026lt;\u0026lt; 12); } 计算状态为未为使用进程数 内核中有一个全局数组,其中每一项为系统中的进程,xv6中设置最多进程数为64个:\nstruct proc proc[NPROC]; 在表示进程的结构体中,有一个成员表示这个进程的状态:\nenum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; // Per-process state struct proc { ... // p-\u0026gt;lock must be held when using these: enum procstate state; ... } 我们可以遍历proc数组,找到所有state != UNUSED的进程的数量(这里一定要看清楚,是状态为未使用的进程数,而不是未使用的进程数):\n// kernel/proc.c uint64 nproc() { struct proc* p; uint64 not_unused = 0; for (p = proc; p \u0026lt; \u0026amp;proc[NPROC]; p++) { if (p-\u0026gt;state != UNUSED) { not_unused++; } } return not_unused; } 完成系统调用 如何获取用户态传递过来的参数和注册系统调用可以参考这篇博客,这里就不赘述了\n我们创建了struct sysinfo结构体变量info后,使用刚刚的freemem和nproc函数来为结构体变量赋值,之后通过copyout函数将内核态中的info拷贝给用户态的struct sysinfo结构体变量。\n这里的实现原理是:用户态下我们使用系统调用传递了一个struct sysinfo指针,其实就是传递了一个内存地址addr;内核态下我们将info中的数据原封不动地搬一份到addr处。这样当用户态访问addr处的内存时就可以获取到想要的数据了。\nuint64 sys_sysinfo() { uint64 addr; argaddr(0, \u0026amp;addr); if (addr \u0026lt; 0) { return -1; } struct proc* p = myproc(); struct sysinfo info; info.freemem = freemem(); info.nproc = nproc(); if (copyout(p-\u0026gt;pagetable, addr, (char*)\u0026amp;info, sizeof(info))) { return -1; } return 0; } ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab2-system-calls/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e这个lab开始我们就正式进入了xv6的世界了,这一次我们可以了解到内核中系统调用的注册和运行原理,这可以说是之后lab的一个基石。\u003c/p\u003e","title":"【MIT6.S081】Lab2 system calls"},{"content":"前言 该Lab通过实现几个命令来熟悉 xv6 及其系统调用\nsleep pingpong primes find xargs 官方实验指导:https://pdos.csail.mit.edu/6.S081/2021/labs/util.html\n个人代码实现仓库:https://github.com/kerolt/xv6-labs-2023\n环境搭建 使用docker创建ubuntu20.04容器,后执行:\napt install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu 之后测试一下:\n首先下载xv6源码:git clone https://github.com/mit-pdos/xv6-riscv.git 运行:make qemu,如果结果如下,说明成功,按下ctrl + a和x退出qemu # ... lots of output ... init: starting sh $ 测试 对于完成的程序,如果想要测试,则在Makefile中的UPROGS中添加:\n$U/_\u0026lt;xxx\u0026gt;\\ 其中的xxx即为程序的名称,如sleep,则为$U/_sleep\\。\n之后,可使用如下方法进行测试:\n./grade-lab-util xxx # or make GRADEFLAGS=xxx grade sleep (easy) 练前开胃菜,使用sleep系统调用。\n#include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char* argv[]) { if (argc != 2) { printf(\u0026#34;Usage: sleep \u0026lt;seconds\u0026gt;\\n\u0026#34;); exit(1); } int time = atoi(argv[1]); sleep(time); return exit(0); } pingpong (easy) 编写一个程序,使用 UNIX 系统调用在两个进程之间通过一对管道 \u0026ldquo;乒乓 \u0026ldquo;传送一个字节,每个管道一个方向。\n该程序需要注意的就是对于两个管道的操作,何时关,关哪个?读写顺序又如何?\n对于父进程,应该先写再读 对于子进程,应该先读再写 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; #define R 0 #define W 1 int main() { int p2c[2], c2p[2]; pipe(p2c); pipe(c2p); int pid = fork(); if (pid == 0) { // chile: read from the parent char buf[32] = {0}; close(c2p[R]); // 不用从子进程读 close(p2c[W]); // 不用从父进程写 read(p2c[R], buf, sizeof(buf)); close(p2c[R]); printf(\u0026#34;%d: received ping\\n\u0026#34;, getpid()); write(c2p[W], \u0026#34;pong\u0026#34;, 4); close(c2p[W]); exit(0); } else { // parent: read from the child char buf[32] = {0}; close(p2c[R]); // 不用从父进程读 close(c2p[W]); // 不用从子进程写 write(p2c[W], \u0026#34;ping\u0026#34;, 4); close(p2c[W]); read(c2p[R], buf, sizeof(buf)); printf(\u0026#34;%d: received pong\\n\u0026#34;, getpid()); close(c2p[R]); exit(0); } } primes (moderate/hard) 使用管道编写并发版质数筛。\n#include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; void Filter(int pipe_fd[2]) { close(pipe_fd[1]); int prime; read(pipe_fd[0], \u0026amp;prime, 4); printf(\u0026#34;prime %d\\n\u0026#34;, prime); int num; if (read(pipe_fd[0], \u0026amp;num, 4) == 0) { exit(0); } int new_pipe_fd[2]; pipe(new_pipe_fd); int pid = fork(); if (pid == -1) { printf(\u0026#34;Fork error!\\n\u0026#34;); exit(1); } else if (pid == 0) { Filter(new_pipe_fd); } else { close(new_pipe_fd[0]); if (num % prime != 0) { write(new_pipe_fd[1], \u0026amp;num, 4); } while (read(pipe_fd[0], \u0026amp;num, 4) \u0026gt; 0) { if (num % prime != 0) { write(new_pipe_fd[1], \u0026amp;num, 4); } } close(new_pipe_fd[1]); close(pipe_fd[0]); wait(0); } } int main() { int pipe_fd[2]; pipe(pipe_fd); int pid = fork(); if (pid == -1) { printf(\u0026#34;Fork error!\\n\u0026#34;); exit(1); } else if (pid == 0) { Filter(pipe_fd); } else { close(pipe_fd[0]); for (int i = 2; i \u0026lt;= 35; i++) { write(pipe_fd[1], \u0026amp;i, 4); // 一个int为4 byte } close(pipe_fd[1]); wait(0); } exit(0); } find (moderate) 实现Unix下的find命令,利用递归处理,要注意关闭文件描述符的时机。\n最关键的是要理解目录(文件夹)也是一种文件,其目录项就是这个文件的内容,所以我们可以通过read系统调用来读取目录项,进而当读取的内容的类型是一个目录时,即可递归调用Find。\n#include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; #include \u0026#34;kernel/fs.h\u0026#34; char* GetName(char* path) { char* p; for (p = path + strlen(path); p \u0026gt;= path \u0026amp;\u0026amp; *p != \u0026#39;/\u0026#39;; p--) {} p++; return p; } void Find(char* dir, char* name) { char buf[512]; char* p; int fd = open(dir, 0); struct dirent de; struct stat st; if (fd \u0026lt; 0) { printf(\u0026#34;Error: open\\n\u0026#34;); return; } if (fstat(fd, \u0026amp;st) \u0026lt; 0) { printf(\u0026#34;Error: stat\\n\u0026#34;); close(fd); return; } if (st.type != T_DIR) { printf(\u0026#34;the current the file is not dictionary\\n\u0026#34;, dir); close(fd); return; } strcpy(buf, dir); p = buf + strlen(buf); *p++ = \u0026#39;/\u0026#39;; while (read(fd, \u0026amp;de, sizeof(de)) == sizeof(de)) { if (de.inum == 0) continue; if (strcmp(de.name, \u0026#34;.\u0026#34;) == 0) continue; if (strcmp(de.name, \u0026#34;..\u0026#34;) == 0) continue; char* cur = p; memmove(cur, de.name, DIRSIZ); cur[DIRSIZ] = 0; if (stat(buf, \u0026amp;st) \u0026lt; 0) { printf(\u0026#34;Error: stat\\n\u0026#34;); continue; } switch (st.type) { case T_FILE: if (strcmp(GetName(buf), name) == 0) { printf(\u0026#34;%s\\n\u0026#34;, buf); } break; case T_DIR: if (strlen(dir) + 1 + DIRSIZ + 1 \u0026gt; sizeof(buf)) { printf(\u0026#34;Error: path too long\\n\u0026#34;); break; } Find(buf, name); break; } } close(fd); } int main(int argc, char* argv[]) { if (argc != 3) { printf(\u0026#34;Usage: find \u0026lt;dir\u0026gt; \u0026lt;file_name\u0026gt;\\n\u0026#34;); exit(1); } Find(argv[1], argv[2]); exit(0); } xargs (moderate) 简单实现Unix上的xargs命令。简单介绍xargs的用法,就是将标准输入作为xargs的参数。更多的介绍,可以看阮一峰老师的博客:https://ruanyifeng.com/blog/2019/08/xargs-tutorial.html\n该命令的可使用fork和exec来实现。\n#include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;kernel/param.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char* argv[]) { if (argc \u0026lt; 2) { printf(\u0026#34;Usage: xargs \u0026lt;params\u0026gt;\\n\u0026#34;); exit(1); } char* child_argv[MAXARG]; char buf[512] = {\u0026#39;\\0\u0026#39;}; int index = 0; for (int i = 1; i \u0026lt; argc; i++) { child_argv[index++] = argv[i]; } sleep(10); // 从标准输入中读取命令到buf中 // 若执行 echo 1 2 3,则标准输入为 1 2 3 int n; while ((n = read(0, buf, sizeof(buf))) \u0026gt; 0) { char* p = buf; for (int i = 0; i \u0026lt; n; i++) { if (buf[i] != \u0026#39;\\n\u0026#39;) continue; if (fork() == 0) { buf[i] = \u0026#39;\\0\u0026#39;; // 例如:echo 1 | xargs echo 2 // 在xargs中,标准输入为1,buf中内容为\u0026#34;1\\n\u0026#34;,child_argv为[\u0026#34;echo\u0026#34;, \u0026#34;2\u0026#34;],index为2 // 程序执行到此处时,buf中内容变为了\u0026#34;1\\0\u0026#34;,child_argv变为了[\u0026#34;echo\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;1\u0026#34;] child_argv[index] = p; // exec的第一个参数是要执行的可执行文件的路径 // 第二个参数是作为新命令的参数数组,数组的第一项为新命令的名称 exec(child_argv[0], child_argv); exit(0); } else { // 在父进程中,跳过buf中的\\n后,是一条新命令的开始 p = \u0026amp;buf[i + 1]; wait(0); } } } exit(0); } ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/mit6.s081lab1-utilities/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e该Lab通过实现几个命令来熟悉 xv6 及其系统调用\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003esleep\u003c/li\u003e\n\u003cli\u003epingpong\u003c/li\u003e\n\u003cli\u003eprimes\u003c/li\u003e\n\u003cli\u003efind\u003c/li\u003e\n\u003cli\u003exargs\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e官方实验指导:\u003ca href=\"https://pdos.csail.mit.edu/6.S081/2021/labs/util.html\"\u003ehttps://pdos.csail.mit.edu/6.S081/2021/labs/util.html\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e个人代码实现仓库:\u003ca href=\"https://github.com/kerolt/xv6-labs-2023/\"\u003ehttps://github.com/kerolt/xv6-labs-2023\u003c/a\u003e\u003c/p\u003e","title":"【MIT6.S081】Lab1 utilities"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\n这个Project需要我们实现一个缓存池,减少对于磁盘的频繁IO。开始慢慢上强度了,细节拉满!\nTask1 - LRU-K Replacement Policy 什么是LRU算法?LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。\nLRU-K是一种增强型的LRU,其主要思想是利用历史访问模式来进行页面置换决策,具体过程如下:\n访问记录:每个页面维护一个访问时间戳列表,记录其最近的k次访问时间。这个列表用于计算向后k距离。 向后k距离:当一个页面被访问时,算法会更新其时间戳列表,并计算向后k距离。向后k距离是当前时间戳与列表中第k个时间戳之间的差值。如果列表中没有k个时间戳,该页面的向后k距离被赋值为正无穷。 页面替换: 在需要替换页面时,算法会遍历所有页面,找到具有最大向后k距离的页面进行替换。 如果存在多个页面的向后k距离为正无穷,算法将选择这些页面中最早的访问时间戳进行替换。 优点:LRU-K相较于传统的LRU算法更为智能,它能更好地适应不同的访问模式,减少不必要的页面置换。通过考虑历史访问,它能够识别出哪些页面可能会被频繁使用,从而提高缓存的命中率。 在BusTub的实现中,LRU-K有着每个frame id与LRUKNode的映射。\nclass LRUKNode { private: [[maybe_unused]] std::list\u0026lt;size_t\u0026gt; history_; [[maybe_unused]] size_t k_; [[maybe_unused]] frame_id_t fid_; [[maybe_unused]] bool is_evictable_{false}; }; 一个node记录了一个frame的id,还有这个frame在最近使用k次的时间戳。\nEvict(frame_id_t* frame_id) : 与所有其他可删除的frame相比,删除具有最大向后k距离的frame。将frame id存储在输出参数中并返回True。如果没有可替换的frame,则返回False。具有少于k个访问记录的frame被给定“无穷”作为其向后k距离。如果多个帧具有inf向后k距离,则根据LRU驱逐具有最早时间戳的帧。成功删除帧应减小替换器的大小并删除帧的访问历史记录。 RecordAccess(frame_id_t frame_id) : 每次调用RecordAccess,内置的时间戳就要+1,并且根据frame_id是否在LRU缓存中来决定是更新对应的node还是添加一个新node。 Add:新建一个node,并且初始化,再把node插入缓存 Update:通过frame id找到对应的node,并修改node的历史访问时间戳,更新它在缓存中的访问位置 Remove(frame_id_t frame_id) : 从LRU缓存中删除frame id以及对应的node。要注意删除后对当前“可替换的frame数量”-1。 SetEvictable(frame_id_t frame_id, bool set_evictable) : 控制frame是否可逐出。它还控制着LRUKReplacer的size。 Size() : 返回LRUKReplacer中当前可替换的frame数量。 总的来说,把这个task当成力扣上的一道数据结构设计题来完成就好了。\nTask2 - Disk Scheduler 比较简单,实现两个函数的功能:\nSchedule(DiskRequest r):调度DiskManager执行的请求。DiskRequest结构体指定请求是否为读/写,数据应写入/从何处写入,以及操作的页面ID。DiskRequest还包括一个std::promise,一旦请求被处理,其值应设置为true。 StartWorkerThread():启动处理请求的后台工作线程。在DiskScheduler构造函数中创建工作线程并调用此方法。此方法负责获取排队的请求并将其分派给DiskManager。记住设置DiskRequest回调的值,以向请求发出者发出请求已完成的信号。在调用DiskScheduler的析构函数之前,这不应该返回。 Task3 - Buffer Pool Manager BufferPoolManager内部采用一个原始数组来存放Page的指针。初始时,每个page都在free list中。\n同一个块,在内存中称作“帧(frame)”,在硬盘中称作“页(page)”。缓冲池中会存储pool_size个Page,这些个Page包含硬盘上的数据和一些元信息。\nBufferPoolManager中的page_table_采用page_id作为键,frame_id作为值。如果有某个frame被使用了,那么page_table_中就会插入相应的键值对。BufferPoolManager中的pages_的下标即代表了frame id,我们通过page id可以找到对应的frame id,再通过frame id就可以找到在pages_中的Page了。\n下图来源:https://www.qtmuniao.com/2021/02/10/cmu15445-project1-buffer-pool/\n我们可以从freelist或者replacer中找到frame(优先从freelist中找)。如果free list中有空闲的frame,则使用它;否则从replacer中选出需要换出的frame(replacer的size与buffer pool的size相同)。\n要实现的每个函数在代码头文件中都有较为详细的实现过程,需要注意很多细节。\n需要注意:\n每次执行FetchPage时相当于要使用到某一个页面,既然如此,也要在LRU-K缓存中更新历史访问序列(通过调用RecordAccess); 需要新的frame时,优先从free list中找,其次通过replacer的Evict来获取; 对于页面的pin_count_,只有当某个函数返回的是Page*时,说明这个页是需要使用的,此时其pin_count_ + 1;而只有在Unpin中,才会对pin_count_ - 1; 如果需要替换或新建或删除某个页面时,其脏位位true,则要及时写回磁盘; 错误记录 错误点1:没有保证LRU-K中的原子性 对于当前可替换的frame数量的操作应该是原子性的,可以用atomic_size_t来取代size_t,或者使用锁。\n错误点2:最大向前K距离的计算错误 The LRU-K algorithm evicts a frame whose backward k-distance is maximum of all frames in the replacer. Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access. A frame with fewer than k historical accesses is given +inf as its backward k-distance. When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).\n主要是对这段话的理解不到位,之前是这样:\n// 计算最大向后K距离 int k_dist = node.history_.size() \u0026lt; k_ ? std::numeric_limits\u0026lt;int\u0026gt;::max() : (current_timestamp_ - node.history_[k_ - 1]); if (k_dist \u0026gt; max_k_dist) { evict_frame_id = fid; max_k_dist = k_dist; } else if (k_dist == max_k_dist \u0026amp;\u0026amp; node.history_.back() \u0026lt; node_store_[evict_frame_id].history_.back()) { evict_frame_id = fid; } 但实际应该为:\n// 计算最大向后K距离 int k_dist = node.history_.size() \u0026lt; k_ ? std::numeric_limits\u0026lt;int\u0026gt;::max() : (current_timestamp_ - node.history_[k_ - 1]); if (k_dist \u0026gt; max_k_dist) { evict_frame_id = fid; max_k_dist = k_dist; } else if (k_dist == max_k_dist \u0026amp;\u0026amp; node.history_.back() \u0026lt; node_store_[evict_frame_id].history_.back()) { evict_frame_id = fid; } 当有多个距离为无穷大的frame时,应该选择其历史记录中具有最小时间戳的那个frame!\n错误3:FetchPage找到直接返回时没有pin一下 这里卡了几个测试是因为FetchPage在page_id在缓冲池中时会直接返回,但是我没有在这种情况中对这个page的pin_count_进行+1操作。\n错误4:Unpin中的is_dirty参数的设置 在Unpin中,不能直接将page.is_dirty_设置成参数is_dirty,而是应该用或操作:page.is_dirty_ |= is_dirty;。如果不这样做,那么当原先page.is_dirty_为true时,如果我们通过Unpin设置了false,其is_dirty_就变为了false,但是这个页面仍然是脏页面。\n错误5:FetchPage没有RecordAccess 在FetchPage中,如果缓冲池中有 page id 直接返回时,replacer_也应该执行RecordAccess。因为这时相当于使用了Page,当然要记录更新。\n最终提交 然后Leaderboard的排名有点低了,因为之前都是使用的大锁,希望之后能优化一下。\n小结 这个Project差不多搞了四五天,主体代码用了两天左右,然后就是漫长的修Bug。由于从这个Project开始,就不会在本地给出完整的测试集了,所以在评测平台上也是提交了很多次来检测。通过Discord中的频道,也是找到了一些解决Bug的办法,很多时候是一些细节处自己没有考虑到(错误记录)。好在最后也基本上是自己独立完成的,有点小小的成就感!\n","permalink":"https://kerolt.github.io/posts/%E6%95%B0%E6%8D%AE%E5%BA%93/cmu15-445-fall2023project1-buffer-pool-%E5%B0%8F%E7%BB%93/","summary":"\u003cblockquote\u003e\n\u003cp\u003e该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e这个Project需要我们实现一个缓存池,减少对于磁盘的频繁IO。开始慢慢上强度了,细节拉满!\u003c/p\u003e","title":"【CMU15-445 Fall2023】Project1 Buffer Pool 小结"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\nproject0只在task4中浅浅涉及了一点BusTub的内容,其他都是检测我们对于C++的一个掌握,主要涉及智能指针和C++的常用特性(dynamic_cast、std::move、并发与锁等)。\nTask1 - Copy-On-Write Trie 如果做过力扣上的208. 实现 Trie (前缀树),实现Get和Put这两个操作会更容易。虽然这个task要求我们要使用COW,但是总体的思路是差不多的。\nGet 这个比较简单,顺序遍历key去找节点的子节点就行。值得注意的是有值节点的类型为TrieNodeWithValue,其继承自TrieNode。当遍历玩key并找到节点后,使用dynamic_cast将其转换为TrieNodeWithValue*,如果这个节点不是一个有值的节点,则dynamic_cast会转换失败返回nullptr,否则会返回转换后的指针,通过这个指针获取最终的值即可。\nPut Put的基本思路就是遍历key的同时创建相应的节点,难点在于如何实现“copy on write”。\n看官网的描述,我们要做的就是在需要修改Trie树(例如进行Put操作或者Remove操作)时,需要返回一颗新的Trie树,也就是我们当前的操作不能影响之前的Trie树的结构,这就需要使用到TrieNode::Clone()函数来拷贝一份需要修改的节点,这样才不会影响之前Trie树中的节点。同时,在这个过程中,我们需要尽可能地使用已有节点,举个🌰:\n只需记住:\n本次的插入或删除操作不会影响上次的Trie树的结构,例如上图如果我使用root来访问还是原来的结构,而用new root来访问就可以访问key值为“ad”的节点的值; 尽可能利用已有的节点,上图我们为了不影响之前的Trie树,我们必须拷贝根节点下“a”路径的子节点,插入一个新的节点也需要创建,但是值为“233”和“C++”的节点我们可以利用。 Remove 与Put不同,我使用递归来进行删除,这是因为Put可以顺序遍历key值并不断向下添加节点,而Remove需要从要删除的节点不断向上进行删除。\n删除不合法的情况有:\n碰到空节点 无法再继续往下查找key(key不存在) 这时按照任务要求应该返回原来的Trie树。\nTask2 - Concurrent Key-Value Store Get 加锁获取root_,然后调用之前再Task1中实现的Get操作获取key对应的value,如果value不为nullptr,则将root和value封装为ValueGuard。\nPut、Remove 这两个操作的逻辑相同。由于之前我们实现Trie的三个操作时用到了COW,因此每次Put、Remove时返回的都是一个新的Trie,那么我们在Task2中的操作中要用全程获取写锁(ensure there is only one writer at a time),然后使用Put or Remove获取新的Trie,接着获取root_lock_,最后用获取的新的Trie更新root_。\nTask3 - Debugging 这个没什么太多需要说明的。选择Clion或VSCode配置好环境打断点Debug就行,当然使用print大法也是可以的haha。注意这个task虽然有给你单元测试文件,但是运行肯定是不通过,因为问题答案并不在源码中,我们只能在gradescope才能检测自己做的是否正确。\n(我所做的project所属课程是fall2023版本,如果是spring2023版本,task3在本地测试和gradescope上的测试可能会有区别,是随机数的问题,相关老师有在Discord上说明)\nTask4 - SQL String Functions 这个task需要我们为BusTub实现两个简单的函数:lower和upper。实现并不难,找到需要修改的位置,添加相关处理逻辑和异常操作。\n完成这个task我认为需要对string_expression.h这个文件的内容有一定理解,要明白如何判断获取的操作是lower还是upper,还有在plan_func_call.cpp中处理非法操作。总体来说不难。\n提交 本地跑了测试代码还不够,课程有为我们这些非CMU的学生准备检测平台gradescope,如何加入课程可以看这里。\n在提交之前,需要使用进行clang-tidy检测,并通过python3 gradescope_sign.py生成签名,这样才能通过平台的预检测。\n这里贴一个通关的截图hh:\n总结 这个project0我也是做了两三天(还是太菜了),在其中我更好地巩固了C++中的一些知识,例如智能指针、移动赋值、dynamic_cast。与之前做Xv6的Lab不同,这次的CMU15-445在网上基本没有关于实现的源代码,这就让我无法直接通过代码来学习了。当然这是课程的要求,希望我们能一起构建一个良好的学习氛围,鼓励我们独立思考完成,与他人交流而不是直接要代码,我觉得这是一个很锻炼自己的过程,网上大多是一些思路的介绍(我写的这篇博客也是自己实现的简单思路,希望没有违反课程的要求),我们在没思路时参考一下这些博客,然后再通过自己来完成代码,这比直接看别人写好的代码来说更能够提升自己!希望我能把接下来的几个project都完成,尽量不烂尾!\n","permalink":"https://kerolt.github.io/posts/%E6%95%B0%E6%8D%AE%E5%BA%93/cmu15-445-fall2023project0-c++-primer-%E5%B0%8F%E7%BB%93/","summary":"\u003cblockquote\u003e\n\u003cp\u003e该系列博客只是为了记录自己在写Lab时的思路,按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流!\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eproject0只在task4中浅浅涉及了一点BusTub的内容,其他都是检测我们对于C++的一个掌握,主要涉及智能指针和C++的常用特性(dynamic_cast、std::move、并发与锁等)。\u003c/p\u003e","title":"【CMU15-445 Fall2023】Project0 C++ Primer 小结"},{"content":" 【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了从零开始重写sylar C++高性能分布式服务器框架和代码随想录中的文档。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\nhook函数的具体定义实现可以在这里查看:Github: src/hook.cpp\n该协程库框架的目标并不是做成类似goroutine那样,而是希望能够通过协程来提高IO处理的效率。因此,对于每个文件描述符fd,我们都希望它有一个读写IO的超时时间。\nhook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都转成异步,以提高性能。\n需要Hook的几类函数 在sylar的设计中,只针对socket fd进行hook(因为我们更关心的是网络IO),也就是如果我们操作的不是socket fd,那么就会使用原来的API。\nsylar对如下三类函数进行了hook:\nsleep延时系列接口:包括sleep/usleep/nanosleep。对于这些接口的hook,只需要给IO协程调度器注册一个定时事件,在定时事件触发后再继续执行当前协程即可。当前协程在注册完定时事件后即可yield让出执行权 socket IO系列接口:包括read/write/recv/send\u0026hellip;等,connect及accept也可以归到这类接口中。这类接口的hook首先需要判断操作的fd是否是socket fd,以及用户是否显式地对该fd设置过非阻塞模式,如果不是socket fd或是用户显式设置过非阻塞模式,那么就不需要hook了,直接调用操作系统的IO接口即可。如果需要hook,那么首先在IO协程调度器上注册对应的读写事件,等事件发生后再继续执行当前协程。当前协程在注册完IO事件即可yield让出执行权。 socket/fcntl/ioctl/close等接口:这类接口主要处理的是边缘情况,比如分配fd上下文,处理超时及用户显式设置非阻塞问题。 Hook的实现 我们hook的所有函数,都要与原来的API的行为保持一致(使用这些hook api的时候就好像使用的原来的api)。例如原来的API的返回值通常用0表示成功,-1表示失败\n在 Sylar 中,Hook 的实现通常涉及以下几个关键方面:\n一、函数指针替换\n保存原始函数指针:首先,需要保存被 Hook 函数的原始实现的函数指针。这可以通过在程序启动时或者在首次需要 Hook 的时候,获取原始函数的地址并存储起来。例如,可以定义一个与被 Hook 函数具有相同签名的函数指针变量,并将其初始化为指向原始函数的地址。 替换函数指针:然后,将被 Hook 函数的入口地址替换为自定义的 Hook 函数的地址。这样,当程序调用被 Hook 函数时,实际上会执行 Hook 函数。 二、参数传递和返回值处理\n参数传递:在 Hook 函数中,需要接收与被 Hook 函数相同的参数。这可以通过将参数直接传递给 Hook 函数,或者使用一些技术(如函数调用栈的分析)来获取参数的值。如果被 Hook 函数是 int func(int a, char* b),那么 Hook 函数也应该具有相同的参数列表 int hook_func(int a, char* b)。 返回值处理:Hook 函数需要根据需要处理被 Hook 函数的返回值。可以选择直接返回被 Hook 函数的原始返回值,或者根据特定的逻辑修改返回值后再返回。 三、条件判断和控制\nHook 启用 / 禁用:通常会提供一种机制来启用或禁用 Hook 功能。这可以通过一个全局变量、配置文件或者运行时参数来控制。我们可以在代码中定义一个布尔变量,如 bool hook_enable,当它为真时启用 Hook 功能,为假时直接调用原始函数而不执行 Hook 函数。 特定条件下的 Hook:可以根据特定的条件来决定是否执行 Hook 函数。例如,可以检查参数的值、函数的调用者、当前的运行环境等条件,只有在满足特定条件时才执行 Hook 函数。 FdManager 我们会通过FdContext类(注意与IOManager中的FdContext进行区分)来保存fd的一些状态,例如fd是否关闭了,是否设置为非阻塞,其读写事件超时时间是多少等。\nsleep API 对sleep,usleep,nanosleep三个函数进行hook操作,其逻辑一致:sleep类函数会阻塞当前线程,那么我们的改造方法就是用一个定时器来代替sleep的休眠阻塞,获取当前运行的协程,然后通过IOManager添加一个定时器,规定时间后再将这个协程加入调度,之后yield这个协程。\nsocket API socket:当使用socket创建套接字fd时,我们需要将它加入到FdManager中。 connect:对于原始的connect,它是一个阻塞调用,直到连接成功或发生错误,如果网络延迟较高或目标主机不可达,可能会导致程序长时间挂起。我们需要将其改造为与异步或非阻塞操作结合。对应的实现方法就是通过设置一个超时时间,到时间后取消文件描述符的写事件。为socket fd添加写事件后,如果添加成功,则yield当前协程,并取消定时器 setsockopt:对于optname为SO_RCVTIMEO和SO_RCVTIMEO的情况,我们需要设置sockfd对应的超时时间。 socket IO API accept、read、readv、recv、recvfrom、recvmsg、write、writev、send、sendto、sendmsg这些函数所要作的hook操作都很类似,不同的地方无非就是读写事件的不同,其处理逻辑和connect相似,所以利用了模板来减少冗余代码。\nother API 还有类似close、ioctl、fcntl的函数,由于我们在之前hook api时处理了文件描述符,因此在这些函数中我们需要对文件描述符进行清理或其他操作。\n","permalink":"https://kerolt.github.io/posts/%E9%A1%B9%E7%9B%AE/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-5%E5%B8%B8%E7%94%A8io%E5%87%BD%E6%95%B0%E7%9A%84hook%E5%8A%9F%E8%83%BD/","summary":"\u003cblockquote\u003e\n\u003cp\u003e【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了\u003ca href=\"https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060952\"\u003e从零开始重写sylar C++高性能分布式服务器框架\u003c/a\u003e和代码随想录中的\u003ca href=\"https://www.programmercarl.com/other/project_coroutine.html#%E4%B8%8B%E8%BD%BD%E6%96%B9%E5%BC%8F\"\u003e文档\u003c/a\u003e。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\u003c/p\u003e\n\u003cp\u003ehook函数的具体定义实现可以在这里查看:\u003ca href=\"https://github.com/kerolt/coroutine-lib/blob/master/src/hook.cpp\"\u003eGithub: src/hook.cpp\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e该协程库框架的目标并不是做成类似goroutine那样,而是希望能够通过协程来提高IO处理的效率。因此,对于每个文件描述符fd,我们都希望它有一个读写IO的超时时间。\u003c/p\u003e\n\u003cp\u003ehook的目的是在不重新编写代码的情况下,把老代码中的socket IO相关的API都\u003cstrong\u003e转成异步\u003c/strong\u003e,以提高性能。\u003c/p\u003e","title":"【动手写协程库 5】常用IO函数的HOOK功能"},{"content":" 【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了从零开始重写sylar C++高性能分布式服务器框架和代码随想录中的文档。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\nIOManager类中具体定义实现可以在这里查看:Github: src/iomanager.cpp\n之前实现的协程调度器的功能其实非常简单,当添加任务后调度器只是单纯的从任务队列中取出任务交给协程去执行。sylar的协程库的关注对象是网络IO,如果采用这么简单的调度就根本没有用到协程的精髓。\nsylar的IO协程调度解决了之前调度器在idle状态下忙等待导致CPU占用率高的问题。IO协程调度器使用一对管道fd来tickle调度协程,当调度器空闲时,idle协程通过epoll_wait阻塞在管道的读描述符上,等管道的可读事件。添加新任务时,tickle方法写管道,idle协程检测到管道可读后退出,调度器执行调度。\nIOManager API IOManager的API如下:\n#ifndef IOMANAGER_H_ #define IOMANAGER_H_ #include \u0026lt;cstddef\u0026gt; #include \u0026lt;sys/epoll.h\u0026gt; #include \u0026lt;atomic\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;mutex\u0026gt; #include \u0026lt;shared_mutex\u0026gt; #include \u0026lt;vector\u0026gt; #include \u0026#34;coroutine.h\u0026#34; #include \u0026#34;scheduler.h\u0026#34; #include \u0026#34;timer.h\u0026#34; // 事件:无、读、写 enum Event { NONE = 0x0, READ = EPOLLIN, WRITE = EPOLLOUT, }; // IO协程调度 class IOManager : public Scheduler, public TimerManager { public: IOManager(size_t threads = 1, bool use_caller = true, const std::string\u0026amp; name = \u0026#34;IOManager\u0026#34;); ~IOManager(); bool AddEvent(int fd, Event event, std::function\u0026lt;void()\u0026gt; cb = nullptr); bool DelEvent(int fd, Event event); bool CancelEvent(int fd, Event event); bool CancelAllEvent(int fd); static IOManager* GetIOManager(); protected: void Idle() override; void Tickle() override; void OnTimerInsertAtFront() override; bool IsStop() override; void ResizeContexts(size_t size); private: // socket fd 上下文 struct FdContext { struct EventContext { Scheduler* scheduler = nullptr; Coroutine::Ptr coroutine; std::function\u0026lt;void()\u0026gt; callback; }; // 根据类型获取对应的上下文 EventContext\u0026amp; GetEventContext(Event\u0026amp; e); void ResetEventContext(EventContext\u0026amp; ectx); void TriggerEvent(Event e); EventContext read_ctx, write_ctx; int fd; Event events = Event::NONE; std::mutex mutex; }; private: int epfd_; int tickle_fd_[2]; std::atomic_size_t pending_evt_cnt_; std::mutex mutex_; std::shared_mutex rw_mutex_; // 利用fd作为下标来获取对应的FdContext*,也可以使用哈希表代替 std::vector\u0026lt;FdContext*\u0026gt; fd_contexts_; }; #endif /* IOMANAGER_H_ */ Scheduler::Run() 我们可以先回顾一下Scheduler::Run()这个函数:\nvoid Scheduler::Run() { LOG \u0026lt;\u0026lt; \u0026#34;Scheduler running...\\n\u0026#34;; SetHookFlag(true); SetThisAsScheduler(); // 如果当前线程不是调度器所在线程,设置调度的协程为当前线程运行的协程 if (std::this_thread::get_id() != sched_id_) { sched_coroutine = Coroutine::GetNowCoroutine().get(); } Coroutine::Ptr idle_co = std::make_shared\u0026lt;Coroutine\u0026gt;([this] { this-\u0026gt;Idle(); }); Coroutine::Ptr callback_co; SchedulerTask task; while (true) { task.Reset(); bool tickle = false; { std::lock_guard lock(mutex_); auto iter = tasks_.begin(); while (iter != tasks_.end()) { // 当前遍历的task已经分配了线程去执行且这个线程不是当前线程,则不用管 if (iter-\u0026gt;thread_id_ \u0026amp;\u0026amp; *iter-\u0026gt;thread_id_ != std::this_thread::get_id()) { ++iter; tickle = true; continue; } if (iter-\u0026gt;coroutine_ \u0026amp;\u0026amp; iter-\u0026gt;coroutine_-\u0026gt;GetState() != Coroutine::READY) { LOG \u0026lt;\u0026lt; \u0026#34;Coroutine task\u0026#39;s state should be READY!\\n\u0026#34;; assert(false); } task = *iter; tasks_.erase(iter++); active_threads_++; break; } // 有任务可以去执行,需要tickle一下 tickle |= (iter != tasks_.end()); } if (tickle) { Tickle(); } // 子协程执行完毕后yield会回到Run()中 // 注意,每次运行了一个task后需要Reset一下 if (task.coroutine_) { // 任务类型为协程 task.coroutine_-\u0026gt;Resume(); active_threads_--; task.Reset(); } else if (task.callback_) { // 任务类型为回调函数,将其包装为协程 if (callback_co) { callback_co-\u0026gt;Reset(task.callback_); } else { callback_co = std::make_shared\u0026lt;Coroutine\u0026gt;(task.callback_); } callback_co-\u0026gt;Resume(); active_threads_--; callback_co.reset(); task.Reset(); } else { // 无任务,任务队列为空 if (idle_co-\u0026gt;GetState() == Coroutine::FINISH) { LOG \u0026lt;\u0026lt; \u0026#34;Idle coroutine finish\\n\u0026#34;; break; } idle_threads_++; idle_co-\u0026gt;Resume(); // Idle最后Yeild时回到这里 idle_threads_--; } } LOG \u0026lt;\u0026lt; \u0026#34;Scheduler Run() exit\\n\u0026#34;; } 这个函数就是每个线程会启动的协程调度函数,负责管理和执行任务队列中的任务,包括协程和回调函数两种类型的任务,如果任务队列为空,则执行Idle协程。\n在Scheduler::Idle()函数中,仅仅只是做了一个简单的处理:调度器没有停止就让出当前正在执行的协程,我们要做的增强后的IOManager需要重写Idle函数,让它不断等待事件、处理事件、然后再次等待事件的循环过程,它在没有其他协程运行时保持系统的活跃度,并在有事件发生时进行相应的处理。\n重写Idle函数 在IOManager中,我们就需要重写Idle函数,我们需要它是一个不断等待事件、处理事件、然后再次等待事件的循环过程,它在没有其他协程运行时保持系统的活跃度,并在有事件发生时进行相应的处理:\n我们会先找到最近一个定时器的超时时间,并将其与自定义最长超时时间(源码中是5s)进行比较取最小者作为epoll_wait的超时时间 将超时的定时器的回调函数加入调度器 处理epoll_wait送来的事件 将当前线程运行的协程暂停(也就是暂停Idle协程),并将执行权交给调度协程(Scheduler::Run()) 从1.又开始重复执行 具体操作可看代码:\nvoid IOManager::Idle() { LOG \u0026lt;\u0026lt; \u0026#34;idle coroutine start up\\n\u0026#34;; const int MAX_EVENTS = 256; const int MAX_TIMEOUT = 5000; epoll_event events[MAX_EVENTS]{}; while (true) { // LOG \u0026lt;\u0026lt; \u0026#34;in idle now\\n\u0026#34;; if (IsStop()) { LOG \u0026lt;\u0026lt; GetName() \u0026lt;\u0026lt; \u0026#34;idle stop now\\n\u0026#34;; break; } uint64_t next_timeout = GetNextTimerInterval(); int triggered_events; do { // 如果时间堆中有超时的定时器,则比较这个超时定时器的下一次触发的时间与MAX_TIMEOUT(5s),选取最小值作为超时时间 next_timeout = next_timeout != ~0ull ? std::min(static_cast\u0026lt;int\u0026gt;(next_timeout), MAX_TIMEOUT) : MAX_TIMEOUT; // 没有事件到来时会阻塞在epoll_wait上,除非到了超时时间 triggered_events = epoll_wait(epfd_, events, MAX_EVENTS, static_cast\u0026lt;int\u0026gt;(next_timeout)); if (triggered_events \u0026lt; 0 \u0026amp;\u0026amp; errno == EINTR) { continue; } else { break; } } while (true); // 用while(true)的目的是确保在出现特定错误情况时能够重新尝试执行 epoll_wait // 将超时的定时器的回调函数加入调度器 // 这些回调函数的作用可能是关闭连接等操作 std::vector\u0026lt;std::function\u0026lt;void()\u0026gt;\u0026gt; cbs = GetExpiredCbList(); for (auto\u0026amp; cb : cbs) { Sched(cb); } // 处理事件 for (int i = 0; i \u0026lt; triggered_events; i++) { epoll_event\u0026amp; event = events[i]; // 是一个用于通知协程调度的事件 // epoll中监听了用于通知的管道读端fd,当有数据到时即会触发 if (event.data.fd == tickle_fd_[0]) { char buf[256]{}; // 将管道内的数据读完 while (read(tickle_fd_[0], buf, sizeof(buf)) \u0026gt; 0) ; continue; } // FdContext* fd_ctx = (FdContext*) event.data.ptr; FdContext* fd_ctx = static_cast\u0026lt;FdContext*\u0026gt;(event.data.ptr); std::lock_guard lock(fd_ctx-\u0026gt;mutex); // 发生错误时,如果原来的文件描述符上下文(fd_ctx)中有可读或可写事件标志被设置,那么现在将重新触发这些事件 if (event.events \u0026amp; (EPOLLERR | EPOLLHUP)) { event.events |= (EPOLLIN | EPOLLOUT) \u0026amp; fd_ctx-\u0026gt;events; } // 获取fd_ctx对应的事件 int real_event = Event::NONE; if (event.events \u0026amp; EPOLLIN) { real_event |= Event::READ; } if (event.events \u0026amp; EPOLLOUT) { real_event |= Event::WRITE; } if ((fd_ctx-\u0026gt;events \u0026amp; real_event) == Event::NONE) { continue; } // 如果还有剩余事件,则修改;否则将其从epoll中删除 // 注意获取rest_events时不是使用的event.events \u0026amp; ~real_event,因为是要去除fd_ctx-\u0026gt;fd中本次触发的事件 int rest_events = fd_ctx-\u0026gt;events \u0026amp; ~real_event; int op = rest_events ? EPOLL_CTL_MOD : EPOLL_CTL_DEL; event.events = EPOLLET | rest_events; if (epoll_ctl(epfd_, op, fd_ctx-\u0026gt;fd, \u0026amp;event) \u0026lt; 0) { LOG_ERROR \u0026lt;\u0026lt; strerror(errno) \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; continue; } if (real_event \u0026amp; Event::READ) { fd_ctx-\u0026gt;TriggerEvent(Event::READ); --pending_evt_cnt_; } if (real_event \u0026amp; Event::WRITE) { fd_ctx-\u0026gt;TriggerEvent(Event::WRITE); --pending_evt_cnt_; } } // 将当前线程运行的协程暂停(也就是暂停Idle协程),并将执行权交给调度协程 Coroutine::Ptr co_ptr = Coroutine::GetNowCoroutine(); auto co = co_ptr.get(); co_ptr.reset(); co-\u0026gt;Yield(); } } 添加事件 IOManager除了重写Idle函数这个重要点外,还有个重要点就是为指定文件描述符添加事件。\nIOManager内部有一个[FdContext](#IOManger API)结构体用来封装socket fd的上下文(需要绑定的回调函数,对应的事件、协程),并使用一个vector保存这些FdContext。\n我们在添加fd的事件时,需要将其加入vector中,并且需要通过epoll_ctl注册fd的对应事件。\nbool IOManager::AddEvent(int fd, Event event, std::function\u0026lt;void()\u0026gt; cb) { FdContext* fd_ctx = nullptr; { std::shared_lock rw_lock(rw_mutex_); if (fd_contexts_.size() \u0026gt; fd) { fd_ctx = fd_contexts_[fd]; rw_lock.unlock(); } else { rw_lock.unlock(); std::unique_lock rw_lock2(rw_mutex_); ResizeContexts(fd * 1.5); fd_ctx = fd_contexts_[fd]; } } std::lock_guard lock(mutex_); if (fd_ctx-\u0026gt;events \u0026amp; event) { LOG_ERROR \u0026lt;\u0026lt; \u0026#34;A fd can\u0026#39;t add same event\\n\u0026#34;; return false; } int op = fd_ctx-\u0026gt;events ? EPOLL_CTL_MOD : EPOLL_CTL_ADD; epoll_event ep_evt{}; ep_evt.events = static_cast\u0026lt;int\u0026gt;(fd_ctx-\u0026gt;events) | EPOLLET | event; ep_evt.data.ptr = fd_ctx; // 在Idle()中将使用fd对应的这个ep_evt int ret = epoll_ctl(epfd_, op, fd, \u0026amp;ep_evt); if (ret) { LOG_ERROR \u0026lt;\u0026lt; \u0026#34;epoll_ctl \u0026#34; \u0026lt;\u0026lt; strerror(errno); return false; } ++pending_evt_cnt_; // 设置fd对应事件的EventContext fd_ctx-\u0026gt;events = static_cast\u0026lt;Event\u0026gt;(fd_ctx-\u0026gt;events | event); // 使用event_ctx相当于使用fd_ctx-\u0026gt;read_ctx or fd_ctx-\u0026gt;write_ctx(注意是auto\u0026amp;而不是auto) auto\u0026amp; event_ctx = fd_ctx-\u0026gt;GetEventContext(event); assert(!event_ctx.scheduler \u0026amp;\u0026amp; !event_ctx.callback \u0026amp;\u0026amp; !event_ctx.coroutine); event_ctx.scheduler = Scheduler::GetScheduler(); if (cb) { event_ctx.callback = cb; } else { // 设置fd相关事件触发时使用的协程为当前 event_ctx.coroutine = Coroutine::GetNowCoroutine(); assert(event_ctx.coroutine-\u0026gt;GetState() == Coroutine::RUNNING); } return true; } ","permalink":"https://kerolt.github.io/posts/%E9%A1%B9%E7%9B%AE/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-4io%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%99%A8/","summary":"\u003cblockquote\u003e\n\u003cp\u003e【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了\u003ca href=\"https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060952\"\u003e从零开始重写sylar C++高性能分布式服务器框架\u003c/a\u003e和代码随想录中的\u003ca href=\"https://www.programmercarl.com/other/project_coroutine.html#%E4%B8%8B%E8%BD%BD%E6%96%B9%E5%BC%8F\"\u003e文档\u003c/a\u003e。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eIOManager\u003c/code\u003e类中具体定义实现可以在这里查看:\u003ca href=\"https://github.com/kerolt/coroutine-lib/blob/master/src/iomanager.cpp\"\u003eGithub: src/iomanager.cpp\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e之前实现的协程调度器的功能其实非常简单,当添加任务后调度器只是单纯的从任务队列中取出任务交给协程去执行。sylar的协程库的关注对象是网络IO,如果采用这么简单的调度就根本没有用到协程的精髓。\u003c/p\u003e\n\u003cp\u003esylar的IO协程调度解决了之前调度器在idle状态下忙等待导致CPU占用率高的问题。IO协程调度器使用一对管道fd来tickle调度协程,当调度器空闲时,idle协程通过epoll_wait阻塞在管道的读描述符上,等管道的可读事件。添加新任务时,tickle方法写管道,idle协程检测到管道可读后退出,调度器执行调度。\u003c/p\u003e","title":"【动手写协程库 4】IO协程调度器"},{"content":" 【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了从零开始重写sylar C++高性能分布式服务器框架和代码随想录中的文档。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\nTimerManager类中具体定义实现可以在这里查看:Github: src/timer.cpp\n通过定时器,我们可以实现给服务器注册定时事件。sylar的定时器采用最小堆设计,所有定时器根据绝对的超时时间点(也就是超时到期的具体时间戳)进行排序,每次取出离当前时间最近的一个超时时间点,计算出超时需要等待的时间,然后等待超时。超时时间到后,获取当前的绝对时间点,然后把最小堆里超时时间点小于这个时间点的定时器都收集起来,执行它们的回调函数。\n定时器相关API如下:\nclass Timer : public std::enable_shared_from_this\u0026lt;Timer\u0026gt; { friend class TimerManager; public: using Ptr = std::shared_ptr\u0026lt;Timer\u0026gt;; bool Cancel(); bool Refresh(); bool Reset(uint64_t ms, bool from_now); private: Timer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, bool recur, TimerManager* manager); Timer(uint64_t next); private: bool is_recur_; // 是否循环定时器 uint64_t exec_cycle_; // 执行周期 uint64_t next_; // 下一次的到期时间 std::function\u0026lt;void()\u0026gt; callback_; TimerManager* manager_; struct Comp { bool operator()(const Timer::Ptr\u0026amp; lt, const Timer::Ptr\u0026amp; rt) const { if (!lt || !rt) { return !lt \u0026amp;\u0026amp; rt; } return lt-\u0026gt;next_ \u0026lt; rt-\u0026gt;next_; } }; }; class TimerManager { friend class Timer; public: TimerManager(); virtual ~TimerManager(); // public 添加定时器 Timer::Ptr AddTimer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, bool is_recur = false); // 添加条件定时器,如果条件成立则定时器才有效 Timer::Ptr AddConditionTimer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, std::weak_ptr\u0026lt;void\u0026gt; cond, bool is_recur = false); // 获取下一个定时器到现在的执行间隔时间 // 如果没有定时器了,就返回uint64_t的最大值 uint64_t GetNextTimerInterval(); // 获取需要执行的定时器的回调函数列表 std::vector\u0026lt;std::function\u0026lt;void()\u0026gt;\u0026gt; GetExpiredCbList(); // 是否还有定时器 bool HasTimer(); protected: virtual void OnTimerInsertAtFront() = 0; void AddTimer(Timer::Ptr timer, std::shared_lock\u0026lt;std::shared_mutex\u0026gt;\u0026amp; lock); private: // 系统时钟是否出现了回绕(rollover)现象,即当前时间比之前记录的时间要小很多 // 用于检测服务器时间是否被调后了 bool DetectClockRollover(uint64_t now_ms); private: std::shared_mutex rw_mutex_; std::set\u0026lt;Timer::Ptr, Timer::Comp\u0026gt; timer_heap_; bool is_tickled_; uint64_t pre_exec_time_; }; 个人感觉最重要的API是AddTimer、GetNextTimerInterval和GetExpiredCbList。\nAddTimer向时间堆中添加超时超时时间到了后的回调函数(利用Timer类来封装)。 GetNextTimerInterval用于获取下一个定时器到现在的执行间隔时间,这会用于IOManager::Idle()中用于与规定的最大超时时间进行比较,用较小者作为epoll_wait的超时时间参数。 GetExpiredCbList获取的是所有超时定时器的回调函数。在IOManager::Idle()会调用这个函数将所有超时定时器的回调函数作为调度任务加入任务队列进行处理。 在定时器中,使用GetElapsedMS()来获取系统自启动来经过的时间,其内部使用clock_gettime来获取时间,这相比于一些传统时间获取函数(如time或gettimeofday)有更高的精度:\n// 获取系统自启动来经过的时间 static uint64_t GetElapsedMS() { struct timespec ts = {0}; clock_gettime(CLOCK_MONOTONIC_RAW, \u0026amp;ts); return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } ","permalink":"https://kerolt.github.io/posts/%E9%A1%B9%E7%9B%AE/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-3%E5%AE%9A%E6%97%B6%E5%99%A8/","summary":"\u003cblockquote\u003e\n\u003cp\u003e【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了\u003ca href=\"https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060952\"\u003e从零开始重写sylar C++高性能分布式服务器框架\u003c/a\u003e和代码随想录中的\u003ca href=\"https://www.programmercarl.com/other/project_coroutine.html#%E4%B8%8B%E8%BD%BD%E6%96%B9%E5%BC%8F\"\u003e文档\u003c/a\u003e。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eTimerManager\u003c/code\u003e类中具体定义实现可以在这里查看:\u003ca href=\"https://github.com/kerolt/coroutine-lib/blob/master/src/timer.cpp\"\u003eGithub: src/timer.cpp\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e通过定时器,我们可以实现给服务器注册定时事件。sylar的定时器采用最小堆设计,所有定时器根据绝对的超时时间点(也就是超时到期的具体时间戳)进行排序,每次取出离当前时间\u003cstrong\u003e最近的一个超时时间点\u003c/strong\u003e,计算出超时需要等待的时间,然后等待超时。超时时间到后,获取当前的绝对时间点,然后\u003cstrong\u003e把最小堆里超时时间点小于这个时间点的定时器都收集起来\u003c/strong\u003e,执行它们的回调函数。\u003c/p\u003e","title":"【动手写协程库 3】定时器"},{"content":"最近的练手项目web-terminal中(也就是一个网页终端,可执行一些命令),在按下键盘后会显示可能匹配的命令列表(假设对应的函数是setHintList),这不仅是按下字母按键会触发,按下删除键、tab键都会触发。那就不得不考虑一个问题,如果我们手速太快,那么setHintList就会频繁触发,但我们只需要响应用户最后一次输入的命令即可,虽然在这个小项目中没啥问题,但是由此可以引出一些对于以后大项目的考虑:如何减小这种多次频繁执行函数带来的性能开销问题?那就是函数防抖~\n函数防抖是一种优化技术,用来限制某个函数在一定时间内被调用的频率。当事件被触发后,它会等待一段时间,如果在这段时间内再次被触发,那么它会重新开始等待。\n下面是一种实现方式:\nexport function buildDebounce(fn: (...arg: any[]) =\u0026gt; any, duration: number = 300) { let timer = -1; return function (this: unknown, ...args: any[]) { if (timer \u0026gt; -1) { clearTimeout(timer); } timer = window.setTimeout(() =\u0026gt; { fn.bind(this)(...args); timer = -1; }, duration); }; } buildDebounce接受一个函数fn和一个可选的时间间隔duration(默认为 300 毫秒)作为参数,并返回一个新的函数。\n内部使用了一个变量timer来跟踪定时器的引用。初始值为 -1,表示没有定时器在运行。 返回的函数在被调用时,首先检查timer是否大于 -1。如果是,说明之前已经有一个定时器在运行,此时会调用clearTimeout清除这个定时器,以取消之前可能正在等待执行的函数调用。 然后,使用window.setTimeout创建一个新的定时器,在duration毫秒后执行传入的函数fn,并通过bind方法确保函数在正确的上下文中执行。 当定时器执行完函数后,将timer重置为 -1,表示没有定时器在运行。 在(this: unknown, ...args: any[])中,this的值是在调用由buildDebounce返回的函数时,根据调用的上下文确定的。也就是当这个返回的函数被调用时,通过保留传入的 this 值,可以确保在最终执行被包裹的函数 fn 时,fn 能够在正确的上下文中执行。\nbuildDebounce的使用如下:\nfunction printMessage(message) { console.log(message); } const debouncedPrint = buildDebounce(printMessage); debouncedPrint(\u0026#39;Hello\u0026#39;); debouncedPrint(\u0026#39;World\u0026#39;); // 如果在 300 毫秒内连续调用 debouncedPrint,只有最后一次调用会在 300 毫秒后执行打印操作。 ","permalink":"https://kerolt.github.io/posts/%E5%89%8D%E7%AB%AF/%E5%87%BD%E6%95%B0%E9%98%B2%E6%8A%96/","summary":"\u003cp\u003e最近的练手项目web-terminal中(也就是一个网页终端,可执行一些命令),在按下键盘后会显示可能匹配的命令列表(假设对应的函数是\u003ccode\u003esetHintList\u003c/code\u003e),这不仅是按下字母按键会触发,按下删除键、tab键都会触发。那就不得不考虑一个问题,如果我们手速太快,那么\u003ccode\u003esetHintList\u003c/code\u003e就会频繁触发,但我们只需要响应用户最后一次输入的命令即可,虽然在这个小项目中没啥问题,但是由此可以引出一些对于以后大项目的考虑:如何减小这种多次频繁执行函数带来的性能开销问题?那就是函数防抖~\u003c/p\u003e","title":"函数防抖"},{"content":"C++ 中,std::enable_shared_from_this类模板和shared_from_this成员函数主要用于在一个类的成员函数中安全地获取指向自身的std::shared_ptr。它们的作用更多是为了确保资源正确管理。\n当一个对象被多个std::shared_ptr管理时,如果在对象内部的成员函数中直接创建新的std::shared_ptr指向自身(也就是this),可能会导致多个独立的引用计数,从而无法正确管理对象的生命周期。而使用shared_from_this可以确保所有指向该对象的std::shared_ptr共享同一个引用计数,从而正确管理对象的生命周期。\n例如:\n#include \u0026lt;iostream\u0026gt; #include \u0026lt;memory\u0026gt; class MyClass : public std::enable_shared_from_this\u0026lt;MyClass\u0026gt; { public: void print() { std::shared_ptr\u0026lt;MyClass\u0026gt; ptr = shared_from_this(); std::cout \u0026lt;\u0026lt; \u0026#34;Use count: \u0026#34; \u0026lt;\u0026lt; ptr.use_count() \u0026lt;\u0026lt; std::endl; } }; int main() { std::shared_ptr\u0026lt;MyClass\u0026gt; obj = std::make_shared\u0026lt;MyClass\u0026gt;(); obj-\u0026gt;print(); return 0; } print函数中使用shared_from_this获取指向自身的std::shared_ptr,确保了与外部创建的std::shared_ptr共享同一个引用计数。\n如果在对象内部的成员函数中直接返回一个指向自身的普通指针,当外部的std::shared_ptr被销毁后,这个普通指针就会变成悬空指针。而使用shared_from_this可以避免这种情况,因为它返回的std::shared_ptr会在引用计数为零时自动释放对象所占用的内存。例如这里不使用enable_shared_from_this和shared_from_this,则对象会多次释放,导致程序出错。\n代码及其执行结果可参见:这里\n","permalink":"https://kerolt.github.io/posts/c++/enable_shared_from_this%E7%9A%84%E4%BD%9C%E7%94%A8/","summary":"\u003cp\u003eC++ 中,\u003ccode\u003estd::enable_shared_from_this\u003c/code\u003e类模板和\u003ccode\u003eshared_from_this\u003c/code\u003e成员函数主要用于在一个类的成员函数中安全地获取指向自身的\u003ccode\u003estd::shared_ptr\u003c/code\u003e。它们的作用更多是为了确保资源正确管理。\u003c/p\u003e","title":"enable_shared_from_this的作用"},{"content":" 【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了从零开始重写sylar C++高性能分布式服务器框架和代码随想录中的文档。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\nScheduler类中其他函数的定义可以在这里查看:Github: src/scheduler.cpp\nSylar的协程调度器是一个N-M模型,意味着N个线程可以运行M个协程,协程能够在线程之间进行切换,也可以被绑定到特定的线程上执行。\n调度器可以由应用程序中的任何线程创建,但创建它的线程(称为caller线程)可以选择是否参与协程的调度。如果caller线程参与调度,那么调度器的线程数会相应减少一个,因为caller线程本身也会作为一个调度线程。\nScheduler相关API如下:\n#ifndef SCHEDULER_H_ #define SCHEDULER_H_ #include \u0026lt;atomic\u0026gt; #include \u0026lt;cstddef\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;list\u0026gt; #include \u0026lt;memory\u0026gt; #include \u0026lt;mutex\u0026gt; #include \u0026lt;thread\u0026gt; #include \u0026lt;utility\u0026gt; #include \u0026lt;vector\u0026gt; #include \u0026#34;coroutine.h\u0026#34; #include \u0026#34;util.h\u0026#34; // 协程调度器 class Scheduler { private: // 调度任务,任务类型可以是协程/函数二选一,并且可指定调度线程 using ThreadIdPtr = std::shared_ptr\u0026lt;std::thread::id\u0026gt;; struct SchedulerTask { ThreadIdPtr thread_id_; Coroutine::Ptr coroutine_; std::function\u0026lt;void()\u0026gt; callback_; SchedulerTask() {} SchedulerTask(Coroutine::Ptr co, ThreadIdPtr id) : coroutine_(co) , thread_id_(std::move(id)) {} SchedulerTask(std::function\u0026lt;void()\u0026gt; callback, ThreadIdPtr id) : callback_(callback) , thread_id_(std::move(id)) {} void Reset() { thread_id_.reset(); callback_ = nullptr; coroutine_ = nullptr; } }; public: Scheduler(size_t threads = 1, bool use_caller = true, const std::string\u0026amp; name = \u0026#34;scheduler\u0026#34;); virtual ~Scheduler(); std::string GetName() const { return name_; } void Start(); void Stop(); template \u0026lt;typename TaskType\u0026gt; void Sched(TaskType t, ThreadIdPtr id = nullptr) requires(std::invocable\u0026lt;TaskType\u0026gt; || std::same_as\u0026lt;TaskType, Coroutine::Ptr\u0026gt;) { bool is_need_tick = false; { std::lock_guard lock(mutex_); is_need_tick = tasks_.empty(); SchedulerTask task(t, id); if (task.callback_ || task.coroutine_) { tasks_.push_back(task); } } if (is_need_tick) { Tickle(); } } public: static Scheduler* GetScheduler(); static Coroutine* GetSchedCoroutine(); protected: virtual void Tickle(); void Run(); void SetThisAsScheduler(); virtual void Idle(); virtual bool IsStop(); bool HasIdleThreads() { return idle_threads_ \u0026gt; 0; } private: std::string name_; std::mutex mutex_; std::vector\u0026lt;std::thread\u0026gt; thread_pool_; std::vector\u0026lt;std::thread::id\u0026gt; thread_ids_; std::list\u0026lt;SchedulerTask\u0026gt; tasks_; size_t threads_size_; std::atomic_size_t active_threads_{0}; std::atomic_size_t idle_threads_{0}; std::thread::id sched_id_; // use_caller为true时,调度器所在的线程id Coroutine::Ptr sched_co_; // use_caller为true时调度器所在线程的调度协程 bool is_stop_; bool is_use_caller_; }; #endif /* SCHEDULER_H_ */ 调度器的工作流大致为:\n协程调度器在初始化时可传入线程数和一个布尔型的use_caller参数,表示是否使用caller线程。在使用caller线程的情况下,线程数自动减一,并且调度器内部会初始化一个属于caller线程的调度协程并保存起来(比如,在main函数中创建的调度器,如果use_caller为true,那调度器会初始化一个属于main函数线程的调度协程)。 调度器创建好后 ,即可调用调度器的Sched函数向调度器添加调度任务,但此时调度器并不会立刻执行这些任务,而是将它们保存到内部的一个任务队列中。 调用Scheduler::Start()函数启动调度 。调用Start会创建调度线程池,线程数量由初始化时的线程数和use_caller确定。调度线程一旦创建,就会立刻从任务队列里取任务执行。比较特殊的一点是,如果初始化时指定线程数为1且use_caller为true,那么Start方法什么也不做,因为不需要创建新线程用于调度。并且,由于没有创建新的调度线程,那只能由caller线程的调度协程来负责调度协程(这里有点绕),而caller线程的调度协程的执行时机与Start函数并不在同一个地方。caller线程的调度协程的执行时机在Stop函数中。 接下来是调度协程,对应Scheduler::Run() 。调度协程负责从调度器的任务队列中取任务执行。取出的任务即子协程,每个子协程执行完后都必须返回调度协程,由调度协程重新从任务队列中取新的协程并执行。如果任务队列空了,那么调度协程会切换到一个idle协程,等有新任务进来时,idle协程才会退出并回到调度协程,重新开始下一轮调度。(在Scheduler中,idle函数的定义十分简单粗暴,因为实际使用协程库时并不是直接使用Scheduler类,而是使用它的派生类,在派生类中将会实现更为完善的调度) 接下来是添加调度任务,对应Scheduler::Sched() ,这个方法支持传入协程或函数,并且支持一个线程id参数,表示是否将这个协程或函数绑定到一个具体的线程上执行。如果任务队列为空,那么在添加任务之后,要调用一次tickle方法以通知各调度线程的调度协程有新任务来了。在执行调度任务时,还可以通过调度器的GetScheduler()获取到当前调度器,再通过Sched函数继续添加新的任务,这就变相实现了在子协程中创建并运行新的子协程的功能。 接下来是调度器的停止。调度器的停止行为要分两种情况讨论,首先是use_caller为false的情况,这种情况下,由于没有使用caller线程进行调度,那么只需要简单地等各个调度线程的调度协程退出就行了。如果use_caller为true,表示caller线程也要参于调度,这时,调度器初始化时记录的属于caller线程的调度协程就要起作用了,在调度器停止前,应该让这个caller线程的调度协程也运行一次,让caller线程完成调度工作后再退出。如果调度器只使用了caller线程进行调度,那么所有的调度任务要在调度器停止时才会被调度。 调度器中最重要的一个函数我认为就是Run()函数了,这个函数用于协程的调度,或者,你可以将他理解为是一个调度协程(名词)。\n创建Scheduler时会为每一个内部线程池中的每一个线程都绑定一个调度协程,线程数量默认为1,此时也默认会使用caller线程,也就是使用的主线程。调度协程Scheduler::Run()会从任务队列Task Queue中不断去取任务去执行。如果有任务可执行,那就切换至任务协程执行,任务协程执行完毕后又切换回调度协程;无任务执行时,调度协程切换至Idle协程进行等待。\n// 用于协程的调度 void Scheduler::Run() { LOG \u0026lt;\u0026lt; \u0026#34;Scheduler running...\\n\u0026#34;; SetThisAsScheduler(); // 如果当前线程不是调度器所在线程,设置调度的协程为当前线程运行的协程 if (std::this_thread::get_id() != sched_id_) { sched_coroutine = Coroutine::GetNowCoroutine().get(); } Coroutine::Ptr idle_co = std::make_shared\u0026lt;Coroutine\u0026gt;([this] { this-\u0026gt;Idle(); }); Coroutine::Ptr callback_co; SchedulerTask task; while (true) { task.Reset(); bool tickle = false; { std::lock_guard lock(mutex_); auto iter = tasks_.begin(); while (iter != tasks_.end()) { // 当前遍历的task已经分配了线程去执行且这个线程不是当前线程,则不用管 if (iter-\u0026gt;thread_id_ \u0026amp;\u0026amp; *iter-\u0026gt;thread_id_ != std::this_thread::get_id()) { ++iter; tickle = true; continue; } if (iter-\u0026gt;coroutine_ \u0026amp;\u0026amp; iter-\u0026gt;coroutine_-\u0026gt;GetState() != Coroutine::READY) { LOG \u0026lt;\u0026lt; \u0026#34;Coroutine task\u0026#39;s state should be READY!\\n\u0026#34;; assert(false); } task = *iter; tasks_.erase(iter++); active_threads_++; break; } // 有任务可以去执行,需要tickle一下 tickle |= (iter != tasks_.end()); } if (tickle) { Tickle(); } // 子协程执行完毕后yield会回到Run()中 if (task.coroutine_) { // 任务类型为协程 task.coroutine_-\u0026gt;Resume(); active_threads_--; } else if (task.callback_) { // 任务类型为回调函数 if (callback_co) { callback_co-\u0026gt;Reset(task.callback_); } else { callback_co = std::make_shared\u0026lt;Coroutine\u0026gt;(task.callback_); } callback_co-\u0026gt;Resume(); active_threads_--; } else { // 无任务,任务队列为空 if (idle_co-\u0026gt;GetState() == Coroutine::FINISH) { LOG \u0026lt;\u0026lt; \u0026#34;Idle coroutine finish\\n\u0026#34;; break; } idle_threads_++; idle_co-\u0026gt;Resume(); // Idle最后Yeild时回到这里 idle_threads_--; } } LOG \u0026lt;\u0026lt; \u0026#34;Scheduler Run() exit\\n\u0026#34;; } 这个Scheduler是一个很简单的调度器,要对任务做更好的调度,少不了Idle协程的帮助。Idle协程的具体实现要在之后的IOManager中,其继承自Scheduler,重写了Tickle()、Idle()等函数,并且使用epoll来实现在不同的 I/O 事件发生时,触发相应的处理逻辑。这使得程序可以以非阻塞的方式处理多个 I/O 操作,而不必等待每个操作完成后再进行下一个操作。\n","permalink":"https://kerolt.github.io/posts/%E9%A1%B9%E7%9B%AE/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-2%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%99%A8/","summary":"\u003cblockquote\u003e\n\u003cp\u003e【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了\u003ca href=\"https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060952\"\u003e从零开始重写sylar C++高性能分布式服务器框架\u003c/a\u003e和代码随想录中的\u003ca href=\"https://www.programmercarl.com/other/project_coroutine.html#%E4%B8%8B%E8%BD%BD%E6%96%B9%E5%BC%8F\"\u003e文档\u003c/a\u003e。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eScheduler\u003c/code\u003e类中其他函数的定义可以在这里查看:\u003ca href=\"https://github.com/kerolt/coroutine-lib/blob/master/src/scheduler.cpp\"\u003eGithub: src/scheduler.cpp\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eSylar的协程调度器是一个N-M模型,意味着N个线程可以运行M个协程,协程能够在线程之间进行切换,也可以被绑定到特定的线程上执行。\u003c/p\u003e\n\u003cp\u003e调度器可以由应用程序中的任何线程创建,但创建它的线程(称为caller线程)可以选择是否参与协程的调度。如果caller线程参与调度,那么调度器的线程数会相应减少一个,因为caller线程本身也会作为一个调度线程。\u003c/p\u003e","title":"【动手写协程库 2】协程调度器"},{"content":" 【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了从零开始重写sylar C++高性能分布式服务器框架和代码随想录中的文档。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\nCoroutine类中其他函数的定义可以在这里查看:Github: src/coroutine.cpp\n对于什么是协程,为什么要使用协程,可以看看之前的笔记:【协程】C++20协程初体验。\n对于我们自己来实现协程,其实在之前Xv6的Lab中就有做过:【MIT6.S081】Lab6 multithreading,当初做这个lab的时候没有意识到这就是协程。协程的切换最重要的就是要保存和恢复上下文,在这个lab中,我们通过保存每个协程在切换之前的寄存器的值,以此可用来恢复原来的执行流。\n在sylar的协程库实现中,使用的是Linux原生提供的ucontext来保存协程的上下文和切换。对于协程切换,最重要的两个API就是yield和resume,分别对应协程让出执行权和恢复协程。利用 ucontext 提供的四个函数getcontext()、setcontext()、makecontext()、swapcontext()可以在一个进程中实现协程切换。(这里就不介绍这几个函数的用法来,详细文档可使用man命令查看)\nCoroutine的相关API如下:\n#ifndef COROUTINE_H_ #define COROUTINE_H_ #include \u0026lt;sys/ucontext.h\u0026gt; #include \u0026lt;ucontext.h\u0026gt; #include \u0026lt;cstdint\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;memory\u0026gt; class Coroutine : public std::enable_shared_from_this\u0026lt;Coroutine\u0026gt; { public: using Ptr = std::shared_ptr\u0026lt;Coroutine\u0026gt;; enum State { READY, RUNNING, FINISH }; Coroutine(std::function\u0026lt;void()\u0026gt; callback, size_t stack_size = 0, bool run_in_scheduler = true); ~Coroutine(); void Yield(); void Resume(); void Reset(std::function\u0026lt;void()\u0026gt; callback); uint64_t GetId() const { return id_; } State GetState() const { return state_; } public: static void SetNowCoroutine(Coroutine* co); static Coroutine::Ptr GetNowCoroutine(); static uint64_t TotalCoNums(); static void Task(); static uint64_t GetCurrentId(); private: Coroutine(); private: uint64_t id_ = 0; uint32_t stack_size_ = 0; State state_ = READY; bool is_run_in_sched_; ucontext_t ctx_; // 协程上下文 void* pstack_ = nullptr; // 协程的栈地址 std::function\u0026lt;void()\u0026gt; callback_; }; #endif /* COROUTINE_H_ */ 一个线程可以运行多个协程,但是在某一个时刻只能运行一个协程。我们需要为每个线程设置当前运行的协程的指针和表示线程的主协程的指针。这里用到了C++中的thread_local关键字:\nstatic thread_local Coroutine* cur_coroutine = nullptr; static thread_local Coroutine::Ptr main_coroutine = nullptr; 每当我们需要创建一个协程来执行任务时,我们必须要传入的参数为要执行的函数,而协程的栈大小有默认值,默认使用调度器进行调度:\nCoroutine::Coroutine(std::function\u0026lt;void()\u0026gt; callback, size_t stack_size, bool run_in_scheduler) : callback_(callback) , is_run_in_sched_(run_in_scheduler) { co_count++; stack_size_ = stack_size \u0026gt; 0 ? stack_size : CO_STACK_SIZE; pstack_ = malloc(stack_size_); // getcontext()用于保存当前上下文,以便将来可以从这个点恢复执行 if (getcontext(\u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Coroutine::getcontext\\n\u0026#34;; exit(1); } // 初始化协程上下文 ctx_.uc_link = nullptr; ctx_.uc_stack.ss_sp = pstack_; ctx_.uc_stack.ss_size = stack_size_; // 为已经初始化的上下文设置一个将在该上下文被激活时执行的函数,并为该函数传递参数。 // 为什么这里的第二个参数不直接设置成callback呢?是因为我们自己写协程的话不仅仅只要将任务函数执行完成就行了,执行完成后还要设置协程的状态 makecontext(\u0026amp;ctx_, \u0026amp;Coroutine::Task, 0); } // 每个协程会运行它所绑定的callback,并且在执行完成后将重置该协程的状态,并让出执行权 void Coroutine::Task() { auto cur = GetNowCoroutine(); assert(cur); cur-\u0026gt;callback_(); cur-\u0026gt;callback_ = nullptr; cur-\u0026gt;state_ = FINISH; auto raw_ptr = cur.get(); cur.reset(); raw_ptr-\u0026gt;Yield(); } Yield和Resume函数利用swapcontext来进行协程的切换。由于我们在之后会需要将协程添加到调度器中而不是手动调度,所以要注意协程有使用调度器标志时要与调度器协程进行切换:\n// 让出该协程的执行权,转交到主协程 void Coroutine::Yield() { assert(state_ == FINISH || state_ == RUNNING); SetNowCoroutine(main_coroutine.get()); if (state_ != FINISH) { state_ = READY; } if (is_run_in_sched_) { if (swapcontext(\u0026amp;ctx_, \u0026amp;(Scheduler::GetSchedCoroutine()-\u0026gt;ctx_)) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Yield::swapcontext\\n\u0026#34;; assert(false); } } else { if (swapcontext(\u0026amp;ctx_, \u0026amp;(main_coroutine-\u0026gt;ctx_)) \u0026lt; 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Yield::swapcontext\\n\u0026#34;; exit(1); } } } // 从当前运行的协程恢复到该协程 void Coroutine::Resume() { assert(state_ != FINISH \u0026amp;\u0026amp; state_ != RUNNING); // 每次恢复时需要将当前运行的协程设置为自身 SetNowCoroutine(this); state_ = RUNNING; if (is_run_in_sched_) { if (swapcontext(\u0026amp;(Scheduler::GetSchedCoroutine()-\u0026gt;ctx_), \u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Resume::swapcontext\\n\u0026#34;; assert(false); } } else { if (swapcontext(\u0026amp;(main_coroutine-\u0026gt;ctx_), \u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Resume::swapcontext\\n\u0026#34;; exit(1); } } } ","permalink":"https://kerolt.github.io/posts/%E9%A1%B9%E7%9B%AE/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-1%E5%8D%8F%E7%A8%8B%E5%AE%9A%E4%B9%89/","summary":"\u003cblockquote\u003e\n\u003cp\u003e【动手写协程库】系列笔记是学习sylar的协程库时的记录,参考了\u003ca href=\"https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060952\"\u003e从零开始重写sylar C++高性能分布式服务器框架\u003c/a\u003e和代码随想录中的\u003ca href=\"https://www.programmercarl.com/other/project_coroutine.html#%E4%B8%8B%E8%BD%BD%E6%96%B9%E5%BC%8F\"\u003e文档\u003c/a\u003e。文章并不是对所有代码的详细解释,而是为了自己理解一些片段所做的笔记。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eCoroutine\u003c/code\u003e类中其他函数的定义可以在这里查看:\u003ca href=\"https://github.com/kerolt/coroutine-lib/blob/master/src/coroutine.cpp\"\u003eGithub: src/coroutine.cpp\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e对于什么是协程,为什么要使用协程,可以看看之前的笔记:\u003ca href=\"https://kerolt.github.io/posts/f663291a/\"\u003e【协程】C++20协程初体验\u003c/a\u003e。\u003c/p\u003e\n\u003cp\u003e对于我们自己来实现协程,其实在之前Xv6的Lab中就有做过:\u003ca href=\"https://kerolt.github.io/posts/cb4e63bc/#Uthread-switching-between-threads\"\u003e【MIT6.S081】Lab6 multithreading\u003c/a\u003e,当初做这个lab的时候没有意识到这就是协程。协程的切换最重要的就是要保存和恢复上下文,在这个lab中,我们通过保存每个协程在切换之前的寄存器的值,以此可用来恢复原来的执行流。\u003c/p\u003e","title":"【动手写协程库 1】协程定义"},{"content":" Debian系Linux安装N卡驱动参考这篇博客\n最近Debian终于是装好nvidia的驱动了,但是只能用在X11上,并且相比于不用n卡驱动时的Wayland,X11下的窗口拖动和视频播放会有比较明显的撕裂感,以下是解决的一个方法。\n修改/etc/X11/xorg.conf 文件,在屏幕相关的区域内加上:\nOption \u0026#34;TripleBuffer\u0026#34; \u0026#34;true\u0026#34; Option \u0026#34;ForceFullCompositionPipeline\u0026#34; \u0026#34;on\u0026#34; Option \u0026quot;TripleBuffer\u0026quot; \u0026quot;true\u0026quot; 启用三重缓冲(Triple Buffering)来减少屏幕撕裂,它通过增加一个额外的缓冲区来同步视频输出和显示器的刷新率。 Option \u0026quot;ForceFullCompositionPipeline\u0026quot; \u0026quot;on\u0026quot; 用于强制使用完整的合成管线(Full Composition Pipeline)。启用这个选项可以提高性能,在某些游戏和应用程序中,它可以帮助减少延迟和提高帧率。 ","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/%E8%A7%A3%E5%86%B3x11%E4%B8%8B%E7%AA%97%E5%8F%A3%E6%8B%96%E5%8A%A8%E6%92%95%E8%A3%82%E7%9A%84%E4%B8%80%E4%B8%AA%E6%96%B9%E6%B3%95/","summary":"\u003cblockquote\u003e\n\u003cp\u003eDebian系Linux安装N卡驱动参考这篇\u003ca href=\"https://www.if-not-true-then-false.com/2021/debian-ubuntu-linux-mint-nvidia-guide/\"\u003e博客\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e最近Debian终于是装好nvidia的驱动了,但是只能用在X11上,并且相比于不用n卡驱动时的Wayland,X11下的窗口拖动和视频播放会有比较明显的撕裂感,以下是解决的一个方法。\u003c/p\u003e","title":"解决X11下窗口拖动撕裂的一个方法"},{"content":"我们知道在C++20的协程中,自己实现的Coroutine中必须包含一个Promise,并且这个Promise必须要实现:\nget_return_object() initial_suspend() final_suspend() unhandled_exception() 少了其中任何一个,编译器都会报错。那这是怎么实现的呢?如果是像java那样是一个接口而没有对应的实现从而报错还能理解,但是我们的代码中的Promise完完全全是我们自己写的,也没有使用继承,编译器你怎么知道我在实现协程时少了什么东西呢?\n答案就是:SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)。\nSFINAE 主要应用于函数模板的重载解析过程中。当编译器尝试选择一个合适的函数模板重载版本时,如果某个模板参数的替换导致编译错误,编译器不会立即报错,而是继续尝试其他可能的重载版本。\n在 C++20 协程中,编译器会根据协程函数的定义和 promise_type 的实现来生成协程的执行代码。promise_type 是一个用户定义的类型,它必须提供一些特定的函数,如 get_return_object、initial_suspend、final_suspend、unhandled_exception 和 return_value 等。这些函数定义了协程的行为和状态,编译器会在编译期检查这些函数的存在性和正确性,以确保协程能够正确地执行。\n编译器使用 SFINAE 机制来检查 promise_type 中是否包含特定的函数。具体来说,就是编译器会在协程的实现代码中使用表达式 SFINAE,尝试调用这些函数,并根据调用的结果来确定函数的存在性。\n我们来写个简单的例子,只检查get_return_object函数:\n#include \u0026lt;type_traits\u0026gt; /* * check(int) 是一个重载的模板函数,用于检查U类型是否有get_return_object()函数。 * decltype(std::declval\u0026lt;U\u0026gt;().get_return_object(), std::true_type{}) 会在U具有get_return_object()时返回std::true_type。 * 如果U没有get_return_object(),则会匹配check(...)重载,返回std::false_type。 * value成员是一个常量布尔值,指示T是否具有get_return_object()函数。 */ template \u0026lt;typename T\u0026gt; struct has_get_return_object { template \u0026lt;typename U\u0026gt; static auto check(int) -\u0026gt; decltype(std::declval\u0026lt;U\u0026gt;().get_return_object(), std::true_type{}); template \u0026lt;typename\u0026gt; static std::false_type check(...); static constexpr bool value = decltype(check\u0026lt;T\u0026gt;(0))::value; }; template \u0026lt;typename T\u0026gt; void check_promise_type() { static_assert(has_get_return_object\u0026lt;T\u0026gt;::value, \u0026#34;Promise type must have a get_return_object() method.\u0026#34;); } struct MyCoroutine { struct promise_type { MyCoroutine get_return_object() { return {}; } }; }; int main() { check_promise_type\u0026lt;MyCoroutine::promise_type\u0026gt;(); } 当我们去掉Mycoroutine::promise_type中的get_return_object函数时,check_promise_type函数中就会出发断言失败,从而编译不通过。\n这也就是在将一个函数变为协程时编译器是如何检查的一个大概思路了。不得不感叹,C++真是博大精深。\n","permalink":"https://kerolt.github.io/posts/c++/%E5%8D%8F%E7%A8%8Bc++%E6%98%AF%E5%A6%82%E4%BD%95%E9%80%9A%E8%BF%87sfinae%E6%9D%A5%E6%A3%80%E6%9F%A5promise_type%E7%9A%84/","summary":"\u003cp\u003e我们知道在C++20的协程中,自己实现的Coroutine中必须包含一个\u003ccode\u003ePromise\u003c/code\u003e,并且这个\u003ccode\u003ePromise\u003c/code\u003e必须要实现:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003eget_return_object()\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003einitial_suspend()\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003efinal_suspend()\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eunhandled_exception()\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e少了其中任何一个,编译器都会报错。那这是怎么实现的呢?如果是像java那样是一个接口而没有对应的实现从而报错还能理解,但是我们的代码中的\u003ccode\u003ePromise\u003c/code\u003e完完全全是我们自己写的,也没有使用继承,编译器你怎么知道我在实现协程时少了什么东西呢?\u003c/p\u003e\n\u003cp\u003e答案就是:\u003cstrong\u003eSFINAE\u003c/strong\u003e(Substitution Failure Is Not An Error,替换失败不是错误)。\u003c/p\u003e","title":"【协程】C++是如何通过SFINAE来检查promise_type的"},{"content":"前言 暑假里跟着鱼皮的yuindex项目写了个web终端的小玩具,完成的终端命令不多,但是大体上成型了。由于我打算做一个纯前端的项目,并没有写后端api,这样也方便我最终直接部署到Github Pages上(这样就不用花钱了hh)。\n项目使用vite构建,并使用了ts。在构建部署时遇到了一些问题,由于是第一次将前端项目发布到github pages上,这里记录下。\n生成了额外的源映射文件 例如在执行了pnpm run build后,虽然生成了dist文件夹和其中的内容,但是源文件中的.ts文件会额外生成.js和.js.map文件,.vue文件会额外生成.vue.js和.vue.js.map文件:\n很明显这些额外生成的文件我们在发布部署项目时用不上,而且也无需提交到git上。摸索一番后,知道了这是TypeScript编译器生成的,我们需要修改项目的tsconfig.json文件。\n对于.map文件:\n{ \u0026#34;compilerOptions\u0026#34;: { \u0026#34;sourceMap\u0026#34;: false } } 对于ts生成的对应的js文件:\n{ \u0026#34;compilerOptions\u0026#34;: { \u0026#34;allowJs\u0026#34;: false, // 禁止编译JS文件 \u0026#34;noEmit\u0026#34;: true, // 仅进行类型检查,而不输出任何文件 } } 如何部署到Github Pages 在解决完前面的问题后,我们就可以将dist目录提交到github上了(记住要在.gitignore文件中排除dist目录),这里我们可以使用:\ngit subtree push --prefix dist origin gh-pages 这会将项目的子目录dist推送到远程仓库的gh-pages分支,如果远程仓库没有gh-pages分支,Git 会自动创建它并推送内容。\n接着在Github仓库的Settings中设置部署的位置:\nGithub Pages项目路径错误 由于kerolt.github.io用作了我的博客,这个项目只能作为子项来部署,也就是通过kerolt.github.io/web-terminal来访问。完成前面的步骤后,我虽然能访问,但是其中的内容无法显示,其原因为无法正确获取对应的文件。\n这是因为相当于是在嵌套的公共路径下部署项目,在vite中需指定 base 配置项:\n// vite.config.ts export default defineConfig({ base: \u0026#34;/web-terminal/\u0026#34;, // ... }); 至此,项目已经可以通过https://kerolt.github.io/web-terminal/来访问。\n","permalink":"https://kerolt.github.io/posts/%E5%89%8D%E7%AB%AF/%E8%AE%B0%E4%B8%80%E6%AC%A1%E9%A1%B9%E7%9B%AE%E6%9E%84%E5%BB%BA%E9%83%A8%E7%BD%B2%E7%9A%84%E6%80%BB%E7%BB%93/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e暑假里跟着鱼皮的\u003ccode\u003eyuindex\u003c/code\u003e项目写了个web终端的小玩具,完成的终端命令不多,但是大体上成型了。由于我打算做一个纯前端的项目,并没有写后端api,这样也方便我最终直接部署到\u003cstrong\u003eGithub Pages\u003c/strong\u003e上(这样就不用花钱了hh)。\u003c/p\u003e\n\u003cp\u003e项目使用vite构建,并使用了ts。在构建部署时遇到了一些问题,由于是第一次将前端项目发布到github pages上,这里记录下。\u003c/p\u003e","title":"记一次项目构建部署的总结"},{"content":"在使用手机浏览器搜索时为了免于CSDN垃圾信息的影响,我们可以使用Bing(其他搜索引擎也有类似的功能,但是国内不借助其他方法的情况下体验感最好的也只有Bing了)的“-site:*.csdn.net”来屏蔽CSDN,但是每次输入搜索内容后还要加这么一个字符串真是太麻烦了,因此可以写一个浏览器脚本来完成这一操作。\n在PC上可以直接使用油猴中其他大佬写的脚本,而我在手机上使用Via浏览器时不一定能用(可能可以,不过我还没试~)这些脚本,同时我需要的功能比较简单,故写一个Via浏览器的脚本。\n其实主要就是一个立即执行函数,其中对于输入框注册了keydown的事件,当按下回车键时可以在输入框的文本后添加-site:*.csdn.net。需要注意的是需要使用preventDefault()方法来防止默认的表单提交动作:\n// ==UserScript== // @name Bing CSDN Filter // @namespace https://viayoo.com/ // @version 0.0 // @description 自动在Bing搜索框输入内容后添加-csdn,屏蔽CSDN内容 // @author kerolt // @match https://*.bing.com/* // @grant none // ==/UserScript== (function() { \u0026#39;use strict\u0026#39;; // 获取搜索框中的textarea元素 let searchInput = document.querySelector(\u0026#39;textarea[name=\u0026#34;q\u0026#34;]\u0026#39;); if (searchInput) { // 监听搜索框中的按键事件 searchInput.addEventListener(\u0026#39;keydown\u0026#39;, function(event) { // 检查是否按下回车键 if (event.key === \u0026#39;Enter\u0026#39;) { // 如果输入中没有-site:*.csdn.net,自动添加 if (!searchInput.value.includes(\u0026#39;-site:*.csdn.net\u0026#39;)) { searchInput.value += \u0026#39; -site:*.csdn.net\u0026#39;; } // 提交表单,进行搜索 event.preventDefault(); // 防止默认的表单提交动作 let searchForm = searchInput.closest(\u0026#39;form\u0026#39;); if (searchForm) { searchForm.submit(); // 手动提交表单 } } }); } })(); ","permalink":"https://kerolt.github.io/posts/%E5%89%8D%E7%AB%AF/%E5%86%99%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95%E7%9A%84%E6%B5%8F%E8%A7%88%E5%99%A8%E8%84%9A%E6%9C%AC%E6%9D%A5%E5%B1%8F%E8%94%BDcsdn/","summary":"\u003cp\u003e在使用手机浏览器搜索时为了免于CSDN垃圾信息的影响,我们可以使用Bing(其他搜索引擎也有类似的功能,但是国内不借助其他方法的情况下体验感最好的也只有Bing了)的“\u003ccode\u003e-site:*.csdn.net\u003c/code\u003e”来屏蔽CSDN,但是每次输入搜索内容后还要加这么一个字符串真是太麻烦了,因此可以写一个浏览器脚本来完成这一操作。\u003c/p\u003e","title":"写一个简单的浏览器脚本来屏蔽CSDN"},{"content":"在用C++刷leetcode时,我希望把一个递归函数像js、python那样写在运行函数内部,那么可以使用function和lambda表达式来实现。但如果这个递归函数的参数比较多,那么function的模板参数同样需要写很多,能不能用auto来实现得简单一点呢?\n在C++中,使用lambda表达式实现递归时,由于lambda本身没有显式的类型名,需要通过一些技巧来实现递归调用。使用auto\u0026amp;\u0026amp;作为参数类型是其中一种常见的做法:\nLambda表达式的类型推断:C++中的lambda表达式没有类型名,意味着无法直接在lambda内部调用自身。如果直接将lambda表达式定义为递归函数,会遇到无法识别的编译错误。\n#include \u0026lt;iostream\u0026gt; int main() { auto factorial = [](auto\u0026amp;\u0026amp; self, int n) -\u0026gt; int { if (n \u0026lt;= 1) return 1; return n * self(self, n - 1); // 递归调用lambda }; std::cout \u0026lt;\u0026lt; factorial(factorial, 5) \u0026lt;\u0026lt; std::endl; // 输出120 return 0; } 这里不像js、python直接定义就好了,还需要多写一个通用引用(什么是通用引用,可参考这篇博客),具体为什么我也暂时不清楚,只知道和Y组合子的知识点有关。\n","permalink":"https://kerolt.github.io/posts/c++/c++%E4%B8%AD%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8lambda%E5%AE%9E%E7%8E%B0%E9%80%92%E5%BD%92/","summary":"\u003cp\u003e在用C++刷leetcode时,我希望把一个递归函数像js、python那样写在运行函数内部,那么可以使用\u003ccode\u003efunction\u003c/code\u003e和\u003ccode\u003elambda表达式\u003c/code\u003e来实现。但如果这个递归函数的参数比较多,那么function的模板参数同样需要写很多,能不能用\u003ccode\u003eauto\u003c/code\u003e来实现得简单一点呢?\u003c/p\u003e","title":"C++中如何使用lambda实现递归"},{"content":"为什么我们需要协程? 为什么我们有了线程还需要协程呢?(其实这个问题不应该这么问,协程的出现在线程之前)在一个进程中虽然我们可以创建多个线程,但是在一个进程中能创建的线程数量是有限制的,并且线程的调度仍然受操作系统控制,也就是说线程何时抢占、何时被抢占对于开发者来说都是透明的,并且在调度的过程中还可能涉及到用户态和内核态的切换开销。\n当我们需要去处理一个非常耗时的IO操作时(假设使用的阻塞IO),为了不阻塞当前线程,我们可能会想到新建一个线程去执行这个操作,但是线程的创建和调度也是需要消耗资源的,我们希望有更加轻便的方法,例如:在当前线程中,一个任务遇到阻塞IO时,不要傻傻的停在这里,而是暂停这个任务,转而去执行其他任务,直到IO完成再恢复之前的任务来运行。\n这里看起来是不是像两个函数之间的调用和被调用关系?确实有点像,但区别可大了,在任务1中我们并没有去显式地调用任务2!\n协程的最本质的解释是“可以挂起和恢复的函数”。例如上图中我们遇到耗时的IO操作了,我们就可以主动将当前运行的函数挂起(suspend),让其等待(await)IO操作,让线程去运行其他的函数,直到IO操作完成后再恢复(resume)。上下文切换的时机是靠调用方(写代码的开发人员)自身去控制的,这样协程的调度掌握在我们自己手中,相比与线程减小了系统切换上下文和其他资源的开销。因此协程在需要处理大量I/O操作或者并发任务的情况下提高程序的性能和可维护性。\nC++20带来的协程 C++20带来的协程并不像python或lua中的那么易用,相反,C++给我们提供的是更为底层的操作(不过C++23已经有std::generator这种更高级的抽象,之后也会有更多丰富的用法)。\nC++协程中有很重要的三个概念:\nPromise Awaitable Coroutine Handle Promise promise_type 是每个协程函数的幕后执行对象。它主要负责以下几个任务:\n创建和初始化协程:当协程开始执行时,编译器会通过 promise_type 创建一个对象,并调用其 get_return_object() 方法来获取协程的返回对象。 处理协程的暂停和恢复:协程在暂停时会调用 yield_value() 或 await_suspend() 等方法来处理协程的状态,并决定何时恢复。 处理协程的结束:当协程执行结束时,return_void() 或 return_value() 会被调用,来处理协程的返回结果。 以下是 promise_type 中一些常见的方法:\nget_return_object():用于创建和返回协程的返回对象,一般是协程返回类型的实例。 initial_suspend():返回一个 std::suspend_always 或 std::suspend_never,决定协程在启动时是否立即暂停。 final_suspend():返回一个 std::suspend_always 或 std::suspend_never,决定协程在结束时是否暂停,以允许调用方执行清理操作。 return_void() 或 return_value(T value):用于在协程完成时返回结果。return_void() 用于没有返回值的协程,而 return_value(T) 则用于有返回值的协程。 yield_value(T value):用于生成值并让协程暂停,等待下一次恢复时继续执行。 Awaitable Awaitable 是一个可以与 co_await 表达式一起使用的对象或类型。Awaitable 对象必须提供一组特定的方法,使协程可以暂停执行,并在某个条件满足时继续执行。\nco_await 是 C++ 协程中的一种操作符,用于暂停协程并等待某个条件的满足。当协程遇到 co_await 时,它会暂停,并返回控制权给调用者。协程可以通过调用 co_await some_awaitable 来等待 some_awaitable 完成。\n一个 Awaitable 对象需要提供以下三个方法中的一个或多个:\noperator co_await:返回一个 Awaitable 对象。Awaitable 对象是实际实现等待逻辑的对象。 await_ready():这是 Awaitable 对象上的方法。它返回一个 bool,用于指示是否需要等待。如果返回 true,协程将不会暂停。 await_suspend(std::coroutine_handle\u0026lt;\u0026gt;):这是 Awaitable 对象上的方法。它接受一个 std::coroutine_handle\u0026lt;\u0026gt; 参数,并在协程暂停时调用。这个方法决定协程何时恢复执行。 await_resume():这是 Awaitable 对象上的方法。它在协程恢复时调用,并返回 co_await 表达式的结果。 C++中提供了两个简单的Awaitable:std::suspend_never和std::suspend_always。\nCoroutine Handle std::coroutine_handle 是 C++20 协程库中一个核心的工具类,用于表示和操作协程。它是一个模板类,通常用来指向协程的状态信息。它可以通过协程的 promise_type 访问和控制协程的状态。每个协程在创建时,都会生成一个 std::coroutine_handle,用于管理协程的生命周期。\nstd::coroutine_handle 提供了一系列方法来控制协程的执行,包括以下几个主要功能:\n创建和获取句柄: std::coroutine_handle\u0026lt;\u0026gt;::from_address(void* ptr):通过指针获取一个句柄。 std::coroutine_handle\u0026lt;promise_type\u0026gt;::from_promise(promise_type\u0026amp; promise):通过 promise_type 对象创建一个句柄。 控制协程的执行: void resume():恢复协程的执行。 void destroy():销毁协程并释放其占用的资源。 void operator()():等效于 resume(),恢复协程的执行。 void* address():返回协程句柄的地址,用于低级操作。 检查协程的状态: bool done():检查协程是否已经完成执行。 访问 promise_type: promise_type\u0026amp; promise():获取与当前协程关联的 promise_type 对象,允许访问协程内部状态。 简单示例 这里有一个使用C++20 coroutine来实现挂起和恢复函数的例子。\n#include \u0026lt;coroutine\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;thread\u0026gt; using namespace std::chrono_literals; struct Result { struct Promise { Result get_return_object() { return std::coroutine_handle\u0026lt;Promise\u0026gt;::from_promise(*this); } std::suspend_never initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() {} }; using promise_type = Promise; Result(std::coroutine_handle\u0026lt;Promise\u0026gt; h) : handle(h) {} std::coroutine_handle\u0026lt;Promise\u0026gt; handle; }; Result hello() { std::cout \u0026lt;\u0026lt; \u0026#34;Hello \u0026#34; \u0026lt;\u0026lt; std::endl; co_await std::suspend_always{}; // 挂起hello std::cout \u0026lt;\u0026lt; \u0026#34;world!\u0026#34; \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; \u0026#34;hello one\\n\u0026#34;; } Result hello2() { std::cout \u0026lt;\u0026lt; \u0026#34;你好 \u0026#34; \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; \u0026#34;hello two two\\n\u0026#34;; co_await std::suspend_always{}; // 挂起hello2 std::cout \u0026lt;\u0026lt; \u0026#34;世界!\u0026#34; \u0026lt;\u0026lt; std::endl; } int main() { Result coro = hello(); Result coro2 = hello2(); coro.handle.resume(); // 恢复hello coro2.handle.resume(); // 恢复hello2 } 在main函数中,一开始我们启动了协程hello,输出了“Hello”后被挂起;之后启动了协程hello2,其输出“你好\\nhello two two\\n”后也被挂起。紧接着我们通过coroutine_handle来依次恢复这两个协程,最终输出结果为:\nHello 你好 hello two two world! hello one 世界! References https://zplutor.github.io/2022/03/25/cpp-coroutine-beginner/ https://www.bluepuni.com/archives/stackless-coroutine-and-asio-coroutine https://zhuanlan.zhihu.com/p/355100152?utm_psn=1808059511308697600 https://itnext.io/c-20-coroutines-complete-guide-7c3fc08db89d https://jasonkayzk.github.io/2022/06/03/%E6%B5%85%E8%B0%88%E5%8D%8F%E7%A8%8B/ https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await https://juejin.cn/post/6844903715099377672 ","permalink":"https://kerolt.github.io/posts/c++/%E5%8D%8F%E7%A8%8Bc++20%E5%8D%8F%E7%A8%8B%E5%88%9D%E4%BD%93%E9%AA%8C/","summary":"\u003ch2 id=\"为什么我们需要协程\"\u003e为什么我们需要协程?\u003c/h2\u003e\n\u003cp\u003e为什么我们有了线程还需要协程呢?(其实这个问题不应该这么问,协程的出现在线程之前)在一个进程中虽然我们可以创建多个线程,但是在一个进程中能创建的线程数量是有限制的,并且线程的调度仍然受操作系统控制,也就是说线程何时抢占、何时被抢占对于开发者来说都是透明的,并且在调度的过程中还可能涉及到用户态和内核态的切换开销。\u003c/p\u003e","title":"【协程】C++20协程初体验"},{"content":" 难度:Hard\n标签:位运算;图论;Floyd算法\n链接: https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/\n题目中给出的n范围为1 \u0026lt;= n \u0026lt;= 10,那么说明最多的10个分部的情况下,可以选择关闭的可行情况有2^10种,这个数据量并不大,可以用暴力枚举做出来。\n但是如何知道选择了哪些顶点呢?这里有用到位运算这个很巧妙的方法,一个int型数据有32位,我们最多只要用其中的10位即可表示所有的情况,同时还能用每一位来表示是否选择了某一个顶点(分部)。\n利用Floyd算法可以求解出一个图中任意两个顶点之间的最小距离。当我们选择一个可能的集合时,判断这个集合中有的分部之间的最短距离是否会大于题目要求的最远距离maxDistance,如果有大于的,说明有分部之间最短的距离都无法满足要求。\n需要注意的是,每一种情况我们只需要处理在集合中包含的分部。\nclass Solution { public: int numberOfSets(int n, int maxDistance, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026amp; roads) { vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; g(n, vector\u0026lt;int\u0026gt;(n, INT_MAX / 2)); for (int i = 0; i \u0026lt; n; i++) { g[i][i] = 0; } for (auto\u0026amp; r : roads) { int x = r[0], y = r[1], w = r[2]; g[x][y] = min(g[x][y], w); g[y][x] = min(g[y][x], w); } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; f(n); // 用f来存储每一种情况对应的图 auto check = [\u0026amp;](int set) { for (int i = 0; i \u0026lt; n; i++) { // 只要每种情况对应集合中的分部构成的图 if ((set \u0026gt;\u0026gt; i) \u0026amp; 1) { f[i] = g[i]; } } // Floyd算法,处理f中分部之间的最短距离,需要根据set的值来选择进行计算 // 因为在之前我们只选择了集合中有的分部建图 for (int k = 0; k \u0026lt; n; k++) { if (((set \u0026gt;\u0026gt; k) \u0026amp; 1) == 0) continue; for (int i = 0; i \u0026lt; n; i++) { if (((set \u0026gt;\u0026gt; i) \u0026amp; 1) == 0) continue; for (int j = 0; j \u0026lt; n; j++) { if (((set \u0026gt;\u0026gt; j) \u0026amp; 1) == 0) continue; f[i][j] = min(f[i][j], f[i][k] + f[k][j]); } } } // 检查图中各个分部的最短距离 for (int i = 0; i \u0026lt; n; i++) { if (((set \u0026gt;\u0026gt; i) \u0026amp; 1) == 0) continue; for (int j = 0; j \u0026lt; n; j++) { if (((set \u0026gt;\u0026gt; j) \u0026amp; 1) \u0026amp;\u0026amp; f[i][j] \u0026gt; maxDistance) { return false; } } } return true; }; int res = 0; // 暴力枚举所有情况 for (int i = 0; i \u0026lt; (1 \u0026lt;\u0026lt; n); i++) { res += check(i); // true和false分别隐式转换为1和0 } return res; } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/259.-%E5%85%B3%E9%97%AD%E5%88%86%E9%83%A8%E7%9A%84%E5%8F%AF%E8%A1%8C%E9%9B%86%E5%90%88%E6%95%B0%E7%9B%AE/","summary":"\u003cblockquote\u003e\n\u003cp\u003e难度:Hard\u003c/p\u003e\n\u003cp\u003e标签:位运算;图论;Floyd算法\u003c/p\u003e\n\u003cp\u003e链接: \u003ca href=\"https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/\"\u003ehttps://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"259. 关闭分部的可行集合数目"},{"content":"假设使用vite启动项目后,希望在移动设备上通过ip和端口号来访问项目,通常需要在Linux上开放端口号。\n这里通过firewalld来完成,可能需要自己安装一下:\nsudo apt install firewalld 可通过systemctl来检测其是否工作:\nsystemctl status firewalld 添加端口想要的端口,返回 success 代表成功(–permanent表示永久生效,没有此参数重启后失效)。例如vite的默认端口为5173:\nfirewall-cmd --zone=public --add-port=5173/tcp --permanent 之后执行firewall-cmd --reload,返回 success 代表成功。\n查询端口号:\nfirewall-cmd --zone=public --query-port=5173/tcp 关闭端口号:\nfirewall-cmd --zone=public --remove-port=5173/tcp --permanent ","permalink":"https://kerolt.github.io/posts/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/linux%E5%BC%80%E6%94%BE%E7%AB%AF%E5%8F%A3%E5%8F%B7/","summary":"\u003cp\u003e假设使用vite启动项目后,希望在移动设备上通过ip和端口号来访问项目,通常需要在Linux上开放端口号。\u003c/p\u003e","title":"Linux开放端口号"},{"content":" 难度:Hard\n标签:哈希表;滑动窗口;字符串\n链接: https://leetcode.cn/problems/minimum-window-substring/description/\n比较容易想到要用滑动窗口来解决,使用两个哈希表来记录信息:cnt_t用于记录字符串t中每个字符出现过的次数,cnt_s用于滑动窗口中的字符的出现次数。\n滑动窗口的区间为[left, right],并记录最小的左右区间(这里我使用一个res数组)。移动right有区间,直到移动到s字符串结束部分。将s[right]加入cnt_s中,如果cnt_s包含cnt_t,则:\n若当前区间长度小于记录的最小区间长度,更新这个最小区间长度; 将cnt_s中s[left]出现的次数-1; left右移+1; 重复上面3步,直到cnt_s不包含cnt_t; 最后,若res[0] \u0026lt; 0(最小左区间),说明s 中不存在涵盖 t 所有字符的子串,则返回空字符串 \u0026quot;\u0026quot; ;反之,返回最小左右区间中的字符串。\n这里的“cnt_s包含cnt_t”是什么意思呢?cnt_t中有的字符cnt_s中都要有,并且cnt_t中字符的次数要小于等于cnt_s中字符出现的次数。\nclass Solution { public: string minWindow(string s, string t) { unordered_map\u0026lt;char, int\u0026gt; cnt_s, cnt_t; for (char c : t) { cnt_t[c]++; } int left = 0, right = 0, n = s.length(); int res[2]{-1, n}; while (right \u0026lt; n) { cnt_s[s[right]]++; while (cover(cnt_s, cnt_t)) { if (right - left \u0026lt; res[1] - res[0]) { res[0] = left; res[1] = right; } cnt_s[s[left++]]--; } right++; } return res[0] \u0026lt; 0 ? \u0026#34;\u0026#34; : s.substr(res[0], res[1] - res[0] + 1); } bool cover(unordered_map\u0026lt;char, int\u0026gt;\u0026amp; cnt_s, unordered_map\u0026lt;char, int\u0026gt;\u0026amp; cnt_t) { for (auto\u0026amp; [k, v] : cnt_t) { if (cnt_s.find(k) == cnt_t.end() || cnt_s[k] \u0026lt; v) { return false; } } return true; } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/76.-%E6%9C%80%E5%B0%8F%E8%A6%86%E7%9B%96%E5%AD%90%E4%B8%B2/","summary":"\u003cblockquote\u003e\n\u003cp\u003e难度:Hard\u003c/p\u003e\n\u003cp\u003e标签:哈希表;滑动窗口;字符串\u003c/p\u003e\n\u003cp\u003e链接: \u003ca href=\"https://leetcode.cn/problems/minimum-window-substring/description/\"\u003ehttps://leetcode.cn/problems/minimum-window-substring/description/\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e","title":"76. 最小覆盖子串"},{"content":"记录一下如何配置eslint(其实是怕下一次又被折磨)。\nuni-cli创建项目 由于我是在linux上使用uniapp,无法使用HBuilderX,故采用uni-cli来创建项目并使用vscode进行开发。\nuni-cli创建项目很简单,一行命令即可,这里采用vue3/vite版:\nnpx degit dcloudio/uni-preset-vue#vite my-vue3-project 这里只用来创建项目,发布运行什么的具体可以看官网。\n使用eslint eslint的配置文件和配置方式我感觉有好多种,所以在配置的时候折腾了挺久,最好还是按照官网最新文档来:\neslint:https://eslint.org/docs/latest/use/getting-started eslint-plugin-vue:https://eslint.vuejs.org/ 安装eslint npm init @eslint/config@latest 配置 安装完后,一般项目的根目录下就会出现eslint.config.mjs文件(eslint 9.4.0版本,采用vue框架),主体如下:\nimport pluginVue from \u0026#34;eslint-plugin-vue\u0026#34;; export default [ ...... { rules: { } } ]; 现在我们就要来按照自己的要求来配置,最好是按照官方的文档来定制:eslint-plugin-vue,一般使用的规则会写在rules对象中。\n举个🌰 比如我希望在代码中使用双引号,并且语句使用分号结尾,那么eslint.config.mjs文件就可以这样写:\nimport pluginVue from \u0026#34;eslint-plugin-vue\u0026#34;; export default [ ...pluginVue.configs[\u0026#34;flat/recommended\u0026#34;], { rules: { quotes: [\u0026#34;error\u0026#34;, \u0026#34;double\u0026#34;], semi: [\u0026#34;error\u0026#34;] } } ]; 如果在代码中碰到一些eslint的报错,例如\n那么可以在报错信息中给出的链接网页中查找\n在eslint.config.mjs中的rules中加入\u0026quot;vue/multi-word-component-names\u0026quot;: 0后,报错就消失了。\n","permalink":"https://kerolt.github.io/posts/%E5%89%8D%E7%AB%AF/%E7%94%A8uni-cli%E5%88%9B%E5%BB%BA%E9%A1%B9%E7%9B%AE%E5%90%8E%E4%BD%BF%E7%94%A8eslint/","summary":"\u003cp\u003e记录一下如何配置eslint(其实是怕下一次又被折磨)。\u003c/p\u003e","title":"用uni-cli创建项目后使用eslint"},{"content":" 难度:Hard\n标签:哈希表、字符串、滑动窗口\n链接:https://leetcode.cn/problems/substring-with-concatenation-of-all-words/description/\n在B站上看见一个up的视频,感觉说的很清晰,言简意赅:【五分钟力扣 Leetcode 第30题 串联所有单词的子串除 Python入门算法刷题 极简解法 23行代码 97%】 。\n看完视频后,我觉得关键点在于,可以通过比较两个哈希表是否“相等” 来判断子串是否满足要求。\nclass Solution { public: vector\u0026lt;int\u0026gt; findSubstring(string s, vector\u0026lt;string\u0026gt;\u0026amp; words) { int words_num = words.size(); int uni_len = words[0].size(); int n = s.size(); vector\u0026lt;int\u0026gt; res; unordered_map\u0026lt;string, int\u0026gt; dict; // 记录每个单词的出现次数 for (auto\u0026amp; w : words) { dict[w]++; } // 外循环遍历的次数为单个单词的长度 for (int i = 0; i \u0026lt; uni_len; i++) { int start = i; // start为子串开始的索引 unordered_map\u0026lt;string, int\u0026gt; cache; // cache用于记录内循环中,位于dict中的单词的出现次数 // 内循环,每次增加一个单词长度(即以一个单词长度为最小单位) for (int j = i; j \u0026lt; n; j += uni_len) { string sub = s.substr(j, uni_len); // 判断当前截取的字符串是否存在于dict中 if (dict.find(sub) != dict.end()) { cache[sub]++; // 当sub在cache中出现次数大于dict中,说明当前start不可能为子串的开始索引(因为单词数目都不相等了),不断移动start位置(移动单元为uni_len),直到cache[sub] \u0026lt;= dict[sub] while (cache[sub] \u0026gt; dict[sub]) { string remove_str = s.substr(start, uni_len); cache[remove_str]--; start += uni_len; } // 若cache和dict中内容一致,说明找到了一个符合要求的子串 if (dict == cache) { res.push_back(start); } } else { // 若当前截取字符串不存在于dict中,跳过这个单词 start = j + uni_len; cache.clear(); } } } return res; } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/30.-%E4%B8%B2%E8%81%94%E6%89%80%E6%9C%89%E5%8D%95%E8%AF%8D%E7%9A%84%E5%AD%90%E4%B8%B2/","summary":"\u003cblockquote\u003e\n\u003cp\u003e难度:Hard\u003c/p\u003e\n\u003cp\u003e标签:哈希表、字符串、滑动窗口\u003c/p\u003e\n\u003cp\u003e链接:https://leetcode.cn/problems/substring-with-concatenation-of-all-words/description/\u003c/p\u003e\n\u003c/blockquote\u003e","title":"30. 串联所有单词的子串"},{"content":"最近听说VMware17.5.2个人版可以免费使用了,故在Linux下安装用用,顺便记录一下踩的坑。\n我是想在Linux上想用Uniapp,用wine的体验不是很好,故打算用虚拟机跑Windows。安装好VMware17.5.2后,配置好windows镜像后,点击启动,却报了这样的错:\nCould not open /dev/vmmon: ?????????. Please make sure that the kernel module `vmmon\u0026rsquo; is loaded.\n搜了一下发现是原因为VMware无法访问其必要的内核模块vmmon:\n我首先是尝试了手动启用VMware模块,然后执行命令安装缺失的模块\nsudo /etc/init.d/vmware start sudo vmware-modconfig --console --install-all 但是还是无效,在查看别人的博客后,我手动去编译安装缺失的vmmon和vmnet模块:\ngit clone https://github.com/mkubecek/vmware-host-modules cd vmware-host-modules git checkout workstation-17.5.1 sudo make sudo make install 执行完成后,这两个模块将会安装到/lib/modules/6.1.0-18-amd64/misc下\nkerolt /usr/lib/modules/6.1.0-18-amd64/misc $ ls -l 总计 7164 -rw-r--r-- 1 root root 3996784 5月27日 18:48 vmmon.ko -rw-r--r-- 1 root root 3337384 5月27日 18:48 vmnet.ko 本以为到现在已经结束,结果再次启动VMware,还是不信,于是我想着可能是内核模块没有加载,采用如下命令查看:\nlsmod | grep vmmon 不出意外,没有输出,手动加载模块:\nsudo modprobe vmmon # modprobe: ERROR: could not insert \u0026#39;vmmon\u0026#39;: Key was rejected by service 根据该错误询问ChatGPT,其给出的答复为:启用了安全启动(Secure Boot),导致系统拒绝加载未签名或未正确签名的内核模块。\n重启计算机,进入 BIOS/UEFI 设置,将Secure Boot 设置为 Disabled,之后再次启动VMware时就没有问题了~\n","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/vmware17.5.2%E5%90%AF%E5%8A%A8%E8%B8%A9%E5%9D%91/","summary":"\u003cp\u003e最近听说VMware17.5.2个人版可以免费使用了,故在Linux下安装用用,顺便记录一下踩的坑。\u003c/p\u003e","title":"VMware17.5.2启动踩坑"},{"content":"上一篇文章:股票问题与状态机dp\n本篇文章涉及题目如下:\n123. 买卖股票的最佳时机 III 188. 买卖股票的最佳时机 IV 309. 买卖股票的最佳时机含冷冻期 交易K次问题 在上篇文章的基础上,我们其实需要做的就是在处理函数上加一个参数k代表还可以交易几次,当k \u0026lt; 0时就说明交易次数达到上限\nclass Solution { public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt; cache(n, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 1, vector\u0026lt;int\u0026gt;(2, -1))); function\u0026lt;int(int, int, bool)\u0026gt; dfs = [\u0026amp;](int i, int k, bool hold) { if (k \u0026lt; 0) { return INT_MIN; } if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int\u0026amp; res = cache[i][k][hold]; if (res != -1) { return res; } if (hold) { // 第i天持有 return res = max(dfs(i - 1, k, true), dfs(i - 1, k, false) - prices[i]); } // 第i天未持有 return res = max(dfs(i - 1, k, false), dfs(i - 1, k - 1, true) + prices[i]); }; return dfs(n - 1, k, false); } }; 值得注意的是,该问题的记忆化搜索实现中cache数组应该为三维,cache[i][k][hold]表示第i天,剩余交易次数k次、是否拥有股票的结果的缓存\n使用记忆化搜索是无法通过123. 买卖股票的最佳时机 III的,因此可以将其改成dp解决\nclass Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int k = 2; int n = prices.size(); vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt; dp(n + 1, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 2, vector\u0026lt;int\u0026gt;(2, INT_MIN))); for (int j = 0; j \u0026lt; k + 2; j++) { dp[0][j][0] = 0; } for (int i = 0; i \u0026lt; n; i++) { for (int j = 1; j \u0026lt;= k + 1; j++) { dp[i + 1][j][0] = max(dp[i][j][0], dp[i][j - 1][1] + prices[i]); dp[i + 1][j][1] = max(dp[i][j][1], dp[i][j][0] - prices[i]); } } return dp[n][k + 1][0]; } }; 冷冻期问题 这个问题有点类似与打家劫舍\u0026mdash;不能连续偷相邻的房屋。那么在本问题中,是不是将比较前一天的代码改成比较前前一天的代码就行了?差不多!但是只有在买入股票或卖出股票的时候需要修改,这是因为买入卖出才算一次交易,所以在这两个时间段选一个进行修改即可\nclass Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; cache(n, vector(2, -1)); function\u0026lt;int(int, bool)\u0026gt; dfs = [\u0026amp;](int i, bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int\u0026amp; res = cache[i][hold]; if (res != -1) { return res; } if (hold) { return res = max(dfs(i - 1, true), dfs(i - 2, false) - prices[i]); } return res = max(dfs(i - 1, false), dfs(i - 1, true) + prices[i]); }; return dfs(n - 1, false); } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98%E7%AC%AC%E4%BA%8C%E6%B3%A2/","summary":"\u003cp\u003e上一篇文章:\u003ca href=\"https://kerolt.github.io/2024/04/11/%E7%AE%97%E6%B3%95/%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98%E4%B8%8E%E7%8A%B6%E6%80%81%E6%9C%BAdp/\"\u003e股票问题与状态机dp\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e本篇文章涉及题目如下:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iii/\"\u003e123. 买卖股票的最佳时机 III\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iv/\"\u003e188. 买卖股票的最佳时机 IV\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-with-cooldown/\"\u003e309. 买卖股票的最佳时机含冷冻期\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e","title":"股票问题第二波"},{"content":" 本篇文章思路来源于 @bilibili/灵茶山艾府\n题目描述:https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii\n相对于买卖股票的最佳时机I,该问题可以多次买入和卖出股票以获取最大利益\n启发思路 以prices = [7,1,5,3,6,4]为例,直到最后一天,我们能获取到的最大利润是什么?\n最后一天,也就是第五天的利润(下标从0开始) = 第0天到第5天结束的利润 = 第0天到第四天结束的利润 + 第五天的利润\n将利润分为两部分:\n第五天的利润 什么都不做 买入股票(从 未持有股票 -\u0026gt; 持有股票) 卖出股票(从 持有股票 -\u0026gt; 未持有股票) 第零天到第四天的利润 由此可以清晰的感受到这样一个大问题可以分割为相同的子问题:\n问题:第i天结束,持有/未持有股票的最大利润 子问题:第i - 1天结束,持有/未持有股票的最大利润\n状态机 简单的理解就是状态的转换,如下图就是本题的状态机\n那么如何将状态机与思路结合起来呢?\n我们可以这么想:\n假设f(i, false)代表第i天结束时未拥有股票的利润,f(i, true)代表第i天结束时拥有股票的利润 第i天未持有股票的情况为:第i-1天未持有股票或者第i-1天拥有股票但是第i天时卖出了股票。这时第i天未持有股票的最大利润为f(i, false) = max(f(i - 1, false), f(i - 1, true) + prices[i]) 第i天持有股票的情况未:第i-1天持有股票或者第i-1天未拥有股票但是第i天时买入了股票。这时第i天持有股票的最大利润为f(i, true) = max(f(i - 1, true), f(i - 1, false) - prices[i]) 记忆化搜索 使用上面的思路,采用递归的方法,不难写出下面的算法:\nclass Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); function\u0026lt;int(int, bool)\u0026gt; dfs = [\u0026amp;](int i , bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } if (hold) { // 第i天持有 return max(dfs(i - 1, true), dfs(i - 1, false) - prices[i]); } // 第i天未持有 return max(dfs(i - 1, false), dfs(i - 1, true) + prices[i]); }; return dfs(n - 1, false); } }; 上面还有几个问题\n为什么当i不在范围时return hold ? INT_MIN : 0;?当i不在范围内,那么说明此时就算拥有股票也是非法的,那么其返回值一定不能影响到正常的i值,而用于比较返回值的函数为max,那么将该返回值设置成INT_MAX就是最好的选择了 为什么最后只要返回dfs(n - 1, false)?这时因为最后一天卖出去(也就是未持有股票,false)所拥有的利润一定比最后一天不卖出去(持有股票,true)所拥有的利润高,因此也每次要返回max(dfs(n - 1, false), dfs(n - 1, true))了 好,这时点击提交!会发现超时了!这时因为我们在递归的过程中重复计算了子问题,造成了不必要的开销\n如图,以上红色的部分都是重复的,我们应该在计算的时候保存它们,这就是记忆化搜索\n需要注意的是,记忆化数组cache初始化应该为-1而不是0,是因为计算的利润有可能是0\nclass Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; cache(n, vector\u0026lt;int\u0026gt;(2, -1)); function\u0026lt;int(int, bool)\u0026gt; dfs = [\u0026amp;](int i , bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int res = cache[i][hold]; if (res != -1) { return res; } if (hold) { // 第i天持有 res = max(dfs(i - 1, true), dfs(i - 1, false) - prices[i]); cache[i][hold] = res; return res; } // 第i天未持有 res = max(dfs(i - 1, false), dfs(i - 1, true) + prices[i]); cache[i][hold] = res; return res; }; return dfs(n - 1, false); } }; 递推为dp 由以上的记忆化搜索和状态机思路,就不难将其1:1翻译为递推了\n但由于dp[i - 1][hold]中的i - 1可能为一个负数,故我们需要在dp前添加一个哨兵位,且i都变为i + 1\nclass Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; dp(n + 1, vector\u0026lt;int\u0026gt;(2, 0)); dp[0][0] = 0; dp[0][1] = INT_MIN; for (int i = 0; i \u0026lt; n; i++) { dp[i + 1][0] = max(dp[i][0], dp[i][1] + prices[i]); dp[i + 1][1] = max(dp[i][1], dp[i][0] - prices[i]); } return dp[n][0]; } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98%E4%B8%8E%E7%8A%B6%E6%80%81%E6%9C%BAdp/","summary":"\u003cblockquote\u003e\n\u003cp\u003e本篇文章思路来源于 @bilibili/\u003ca href=\"https://space.bilibili.com/206214\"\u003e灵茶山艾府\u003c/a\u003e\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e题目描述:\u003ca href=\"https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii\"\u003ehttps://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e相对于买卖股票的最佳时机I,该问题可以多次买入和卖出股票以获取最大利益\u003c/p\u003e","title":"股票问题与状态机dp"},{"content":"前言 通过一个项目来学习一下如何设计一个多服务的系统。同时也能学习Spring Cloud Gateway、Dubbo、API签名等一些知识。\n项目架构 用户前台 普通用户 例如管理员发布了一个新的接口A,如果一位开发者并没有注册为该平台的用户,那么即便这位开发者拿到了这个接口地址,也是不可以使用接口A的。\n当他注册为平台用户后,后台会给他生成AccessKey和SecretKey(后文简称为ak和sk)用于API签名认证,这时,用户带着这两个key,就可通过前台提供的在线测试或者客户端SDK来调用想要的接口了。\n管理员 管理员职责就是管理接口,包括接口的上线、下线、添加等\n客户端SDK(api-client-sdk) 若管理员将接口下线,则请求无效\n通过AccessKey和SecretKey来请求api-interface接口服务。其中ak和sk只有用户在平台注册账号后才会分。当然,添加网关后,应该在api-interface前使用添加一层网关服务来避免api接口的直接暴露\nsdk中的请求方法应该和api-interface中提供的接口一一对应,也就是两边应该同步修改\n网关(api-gateway) 使用Spring Cloud Gateway来完成一下功能\n统一鉴权认证:应用 API 签名认证算法校验用户请求的合法性。根据请求拿到用户信息后从数据库中获取ak、sk进行比较,如果相同则代表用户请求合法 公共业务逻辑:对每个接口的调用进行集中的统计 。这类似于Spring MVC中的拦截器和Spring中的AOP 路由转发:前端发送请求到 API 网关,通过网关转发到实际的 API 接口 。这样就避免了直接将完整的接口地址暴露出来 流量染色:给经过网关的请求加上特定的请求头参数,便于让实际的 API 服务确定请求来源及合法性 接口服务(api-interface) 真正实现和提供接口的地方,可以只编写controller层\n抽象接口(api-common) 在网关层由于要进行API鉴权,这避免不料要查询数据库,但是网关服务项目并没有使用MyBatis-Plus,为了避免写重复的代码,利用Dubbo分布式改造,使得网关层可以通过RPC来调用后端服务的方法\n服务后台(api-backend) 最核心的业务层服务!\n用户注册时会自动生成其ak和sk 接口信息的管理,即普通的增删改查,发布、下线接口 接口调用统计(没错,网关是通过rpc调用这里的服务) 当前台用户要进行在线测试时,我们会使用到用户的ak和sk,只有这样才能够比较安全的调用接口;而再通过客户端SDK,就能比较方便地去请求接口 ","permalink":"https://kerolt.github.io/posts/%E5%90%8E%E7%AB%AF/api%E5%BC%80%E6%94%BE%E5%B9%B3%E5%8F%B0/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e通过一个项目来学习一下如何设计一个多服务的系统。同时也能学习Spring Cloud Gateway、Dubbo、API签名等一些知识。\u003c/p\u003e\n\u003ch2 id=\"项目架构\"\u003e项目架构\u003c/h2\u003e\n\u003cp\u003e\u003cimg loading=\"lazy\" src=\"https://gitee.com/kerolt/picture/raw/main/20241216-143608.png\"\u003e\u003c/p\u003e","title":"API开放平台"},{"content":"前言 std::cout重载了\u0026lt;\u0026lt;运算符,这使得写一些很短的代码时很方便。但是如果在多线程的条件下,cout并不是线程安全的。\n举例 举个例子,我们创建5个线程\n#include \u0026lt;iostream\u0026gt; #include \u0026lt;thread\u0026gt; void Test() { std::cout \u0026lt;\u0026lt; \u0026#34;msg1\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg2\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg3\u0026#34; \u0026lt;\u0026lt; \u0026#34; thread_id = \u0026#34; \u0026lt;\u0026lt; std::this_thread::get_id() \u0026lt;\u0026lt; std::endl; } int main() { std::thread threads[5]; for (int i = 0; i \u0026lt; 5; i++) { threads[i] = std::thread(Test); } for (int i = 0; i \u0026lt; 5; i++) { threads[i].join(); } } 实际上,看样子好像控制台应该输出5行内容,但是运行结果可能是这样的\nmsg1 msg2 msg3 thread_id = msg1 msg2 msg3 thread_id = 139926598575808139926606968512 msg1 msg2 msg3 thread_id = 139926590183104 msg1 msg2 msg3 thread_id = 139926455965376 msg1 msg2 msg3 thread_id = 139926581790400 这是因为cout在使用时可能会存在线程之间的打印信息乱串的问题,看一下编译器眼中我们这段程序中的cout是什么样的:\nstd::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::cout, \u0026#34;msg1\u0026#34;), \u0026#34; msg2\u0026#34;), \u0026#34; msg3\u0026#34;), \u0026#34; thread_id = \u0026#34;), std::this_thread::get_id()).operator\u0026lt;\u0026lt;(std::endl); 可以看到,这不是通过单个 std::operator\u0026lt;\u0026lt; 调用完成的,也就是说这个操作并不是原子的\n解决方法 使用std::format(C++20) 使用第三方库,如folly,fmtlib等 使用stringstream \u0026hellip;\u0026hellip; 这里使用stringstream做个演示。将Test函数修改如下:\nstd::stringstream ss; ss \u0026lt;\u0026lt; \u0026#34;msg1\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg2\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg3\u0026#34; \u0026lt;\u0026lt; \u0026#34; thread_id = \u0026#34; \u0026lt;\u0026lt; std::this_thread::get_id() \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; ss.str(); 这样,控制台的输出就不会乱串了。\n","permalink":"https://kerolt.github.io/posts/c++/%E9%81%BF%E5%85%8Dcout%E7%BA%BF%E7%A8%8B%E4%B8%8D%E5%AE%89%E5%85%A8%E7%9A%84%E4%B8%80%E4%B8%AA%E5%81%9A%E6%B3%95/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003estd::cout\u003c/code\u003e重载了\u003ccode\u003e\u0026lt;\u0026lt;\u003c/code\u003e运算符,这使得写一些很短的代码时很方便。但是如果在多线程的条件下,cout并不是线程安全的。\u003c/p\u003e","title":"避免cout线程不安全的一个做法"},{"content":"之前对于移动语义的理解就是使用std::move将一个对象所占有的资源的所有权转移给另一个对象,但是只要使用std::move就足够了吗?这显然是错误的。\n看一下std::move的源码(g++12.2)\n/** * @brief Convert a value to an rvalue. * @param __t A thing of arbitrary type. * @return The parameter cast to an rvalue-reference to allow moving it. */ template\u0026lt;typename _Tp\u0026gt; _GLIBCXX_NODISCARD constexpr typename std::remove_reference\u0026lt;_Tp\u0026gt;::type\u0026amp;\u0026amp; move(_Tp\u0026amp;\u0026amp; __t) noexcept { return static_cast\u0026lt;typename std::remove_reference\u0026lt;_Tp\u0026gt;::type\u0026amp;\u0026amp;\u0026gt;(__t); } 其实move的实现并没有很复杂,粗略一点的理解就是将一个左值强制转换为右值。\nstd::move 并不会真正地移动对象,真正的移动操作是在移动构造函数、移动赋值函数等完成的,std::move 只是将参数转换为右值引用而已。\n写一个简单的例子如下:\n#include \u0026lt;fmt/core.h\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;utility\u0026gt; struct A { A(std::string str) : data(str) {} A(const A\u0026amp;) { puts(\u0026#34;copy\u0026#34;); } A(A\u0026amp;\u0026amp;) { puts(\u0026#34;move\u0026#34;); } std::string data = \u0026#34;default\u0026#34;; }; int main() { A a(\u0026#34;hello\u0026#34;); A a2(std::move(a)); fmt::print(\u0026#34;a: {}, a2: {}\\n\u0026#34;, a.data, a2.data); } 看样子我们使用std::move后a2的data数据应该为“hello”,但是运行结果为\nmove a: hello, a2: default 虽然使用move匹配到了A的移动构造函数,但是在上文提到过,std::move仅仅只是一个强制转换,并没有实现真正的移动!但要是我们不写A中的移动构造函数或是将其设置成default:\nstruct A { A(std::string str) : data(str) {} A(const A\u0026amp;) { puts(\u0026#34;copy\u0026#34;); } A(A\u0026amp;\u0026amp;) = default; std::string data = \u0026#34;default\u0026#34;; }; 这样,运行结果为\na: , a2: hello 这是因为当我们不显式指定移动构造函数(或是拷贝构造函数、移动or拷贝运算符)编译器会自动生成,貌似也一并实现了数据的移动(?这我也还不清楚)\n我们通常使用std::move能够实现标准库中一些资源的转移,是因为标准库中已经实现了这些资源类的移动构造函数or移动赋值运算符。\n","permalink":"https://kerolt.github.io/posts/c++/%E7%BA%A0%E6%AD%A3%E4%B8%80%E4%B8%8B%E5%AF%B9cpp%E7%A7%BB%E5%8A%A8%E8%AF%AD%E4%B9%89%E7%9A%84%E9%94%99%E8%AF%AF%E7%90%86%E8%A7%A3/","summary":"\u003cp\u003e之前对于移动语义的理解就是使用std::move将一个对象所占有的资源的所有权转移给另一个对象,但是只要使用std::move就足够了吗?这显然是错误的。\u003c/p\u003e","title":"纠正一下对cpp移动语义的错误理解"},{"content":"想写这篇博客的原因是在刷力扣的 347. 前 K 个高频元素 一题时,需要使用到优先队列priority_queue,其定义如下:\ntemplate\u0026lt; class T, class Container = std::vector\u0026lt;T\u0026gt;, class Compare = std::less\u0026lt;typename Container::value_type\u0026gt; \u0026gt; class priority_queue; 第三个参数是一个可以自定义的比较类型,其必须满足二元谓词,通常可以使用如下两种方法:\n使用自定义的函数对象 lambda表达式 使用std::greater或std::less(这里就不介绍这种方法了) 以题 347. 前 K 个高频元素 为例,我们要建立一个小根堆,那么代码如下:\n// 方法一 using PII = pair\u0026lt;int, int\u0026gt;; // 比较类,重载了括号运算符 struct Comp { bool operator()(PII\u0026amp; p1, PII\u0026amp; p2) { return p1.second \u0026gt; p2.second; } }; priority_queue\u0026lt;PII, vector\u0026lt;PII\u0026gt;, Comp\u0026gt; pq; 当然,另一种方法就是使用lambda表达式,如下:\n// 方法二 using PII = pair\u0026lt;int, int\u0026gt;; auto comp = [](PII\u0026amp; p1, PII\u0026amp; p2) { return p1.second \u0026gt; p2.second; }; // 注意这里需要使用decltype priority_queue\u0026lt;PII, vector\u0026lt;PII\u0026gt;, decltype(comp)\u0026gt; pq; 但值得注意的是,方法二需要在C++20下才可使用。这是因为priority_queue的第三个模板形参需要的二元谓词要求可复制构造。\nlambda表达式即构造闭包(能够捕获作用域中的变量的无名函数对象)。而在C++20之前,闭包类型非可默认构造,闭包类型没有默认构造函数。C++20及之后,如果没有指定捕获,那么闭包类型拥有预置的默认构造函数。\n而在目前,力扣中C++编译器使用的是clang17,支持C++20,故使用lambda表达式是没有问题的。\n","permalink":"https://kerolt.github.io/posts/c++/c++%E4%B8%ADlambda%E4%B8%8Epriority_queue%E4%B8%80%E8%B5%B7%E4%BD%BF%E7%94%A8/","summary":"\u003cp\u003e想写这篇博客的原因是在刷力扣的 \u003ca href=\"https://leetcode.cn/problems/top-k-frequent-elements/\"\u003e347. 前 K 个高频元素\u003c/a\u003e 一题时,需要使用到优先队列\u003ccode\u003epriority_queue\u003c/code\u003e,其定义如下:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003etemplate\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eT\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eContainer\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003estd\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003evector\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003eT\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eCompare\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003estd\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003eless\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"k\"\u003etypename\u003c/span\u003e \u003cspan class=\"n\"\u003eContainer\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003evalue_type\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003epriority_queue\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e第三个参数是一个可以自定义的比较类型,其必须满足\u003ca href=\"https://zh.cppreference.com/w/cpp/named_req/BinaryPredicate\"\u003e二元谓词\u003c/a\u003e,通常可以使用如下两种方法:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e使用自定义的函数对象\u003c/li\u003e\n\u003cli\u003elambda表达式\u003c/li\u003e\n\u003cli\u003e使用\u003ccode\u003estd::greater\u003c/code\u003e或\u003ccode\u003estd::less\u003c/code\u003e(这里就不介绍这种方法了)\u003c/li\u003e\n\u003c/ol\u003e","title":"C++中lambda与priority_queue一起使用"},{"content":"一般来说,如果我们在C++程序中要使用mysql的库,最简单的就是\ng++ server.cpp -o server -lmysqlclient 但要是在大一点的项目中,在数不清的源文件下使用g++命令来完成,怕是不太现实。\n通常使用的工具为CMake,用其来构建项目,但它又没有包管理功能,对于我们想要使用的库,需要在 CMakeLists.txt 中引入。\n引用的方式我这里选择 find_pakage (其概述和使用方法这里就不过多赘述了)\nset(CMAKE_MODULE_PATH /usr/share/cmake/Modules) find_package(MySQL REQUIRED) add_executable(server ${src_list}) if(MYSQL_FOUND) target_link_libraries(server ${MYSQL_LIBRARIES}) else(MYSQL_FOUND) message(FATAL_ERROR \u0026#34;MySQL library not found\u0026#34;) endif(CURL_FOUND) 对上面的代码做一下解释:\n首先设置一下cmake module的路径,其实就是存放.cmake文件的位置。有时候看一些项目,会发现其根目录下会有一个cmake目录,诶,没错,就和这个一样 使用find_package来引入依赖库 添加可执行程序server 如果使用find_package找到了MySQL库,则将其链接到server上,否则终止构建 值得注意的是,在CMAKE_MODULE_PATH目录下,必须有Find\u0026lt;LibaryName\u0026gt;.cmake 模块,在本例中,即为 FindMySQL.cmake,该文件可在网上找到。\n","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/cmake%E4%B8%AD%E4%BD%BF%E7%94%A8find_pakage%E6%9D%A5%E4%BD%BF%E7%94%A8mysql/","summary":"\u003cp\u003e一般来说,如果我们在C++程序中要使用mysql的库,最简单的就是\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sh\" data-lang=\"sh\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003eg++ server.cpp -o server -lmysqlclient\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e但要是在大一点的项目中,在数不清的源文件下使用g++命令来完成,怕是不太现实。\u003c/p\u003e","title":"CMake中使用find_pakage来使用MySQL"},{"content":"记录学习CMake中碰到的一些问题和笔记\nps:CMake相比xmake来说确实太繁琐,但还是得学习🍭\n现代CMake的命令行构建 在“古代”CMake中,我们想要构建项目要这样:\nmkdir build cd build cmake .. make 太啰嗦了,最后一步可能还用的不是make命令。不过在现代CMake中,提供了更为方便的 -B 和 \u0026ndash;build,如下:\ncmake -B build # 用于生成构建目录。-B 参数后面跟的是一个目录名,这里指定为 build cmake --build build # 用于在已经生成的构建目录中构建项目 这样省去了创建build目录等繁琐的操作,还统一了不同平台上的构建命令。\n如果碰到了有关build缓存的相关问题,可以使用rm删除build目录,或者使用cmake --build build --clean-first来在构建之前先清理构建目录。\n添加源文件的几种方法 假设当前项目文件目录如下:\n. ├── CMakeLists.txt ├── include │ └── add.h └── src ├── add.cpp └── main.cpp 即当前项目的源文件为src目录下的cpp文件,在顶层的CMakeLists.txt中添加源文件目标:\n# 方法一 file(GLOB SOURCE_FILES \u0026#34;${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp\u0026#34;) # 方法二 file(GLOB_RECURSE SOURCE_FILES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/src/*.cpp) # 方法三 aux_source_directory(${PROJECT_SOURCE_DIR}/src SOURCE_FILES) # 添加下面这一句即用上述源文件生成可执行文件app(也可使用add_library生成库文件等) add_executable(app ${SOURCE_FILES}) 可以看到,方法一和方法二虽然都是使用file指令,但内容却稍有区别:\n都是按照通配符批量匹配文件,GLOB和GLOB_RECURSE的区别在于后者允许*递归目录去匹配 对于选项CONFIGURE_DEPENDS,如果不添加,则在src/下添加新文件,由于cmake缓存的原因,SOURCE_FILES变量并不会更新,需要重新执行cmake -B build 设置可执行文件的输出位置 set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) ","permalink":"https://kerolt.github.io/posts/%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/cmakecmake%E4%BD%BF%E7%94%A8%E7%AC%94%E8%AE%B0/","summary":"\u003cp\u003e记录学习CMake中碰到的一些问题和笔记\u003c/p\u003e\n\u003cp\u003eps:CMake相比xmake来说确实太繁琐,但还是得学习🍭\u003c/p\u003e","title":"【CMake】CMake使用笔记"},{"content":"下载源码 链接:https://github.com/chenshuo/muduo/releases/tag/v2.0.2\n编译安装 解压后,在项目根目录中更改 CMakeLists.txt 文件\n如图,将 option 属性注释掉(这是muduo的例子的编译选项,如果开启将增加编译时间)\n之后,执行\n./build.sh ./build.sh install 如果没有出错的话,在与muduo-v2.0.2同目录下将生成一个 build 目录,其中有\nrelease-install-cpp11 ├── include │ └── muduo │ ├── base │ └── net │ ├── http │ └── inspect └── lib 将include目录和lib目录下的内容复制到系统路径下:\nmv include/muduo /usr/local/include mv lib/* /usr/local/lib ok,现在就可以使用muduo库了\n测试使用 测试代码可以参考博客 https://www.cnblogs.com/conefirst/articles/15224039.html\n","permalink":"https://kerolt.github.io/posts/c++/muduo%E7%BD%91%E7%BB%9C%E5%BA%93%E7%9A%84%E5%AE%89%E8%A3%85/","summary":"\u003ch2 id=\"下载源码\"\u003e下载源码\u003c/h2\u003e\n\u003cp\u003e链接:\u003ca href=\"https://github.com/chenshuo/muduo/releases/tag/v2.0.2\"\u003ehttps://github.com/chenshuo/muduo/releases/tag/v2.0.2\u003c/a\u003e\u003c/p\u003e","title":"muduo网络库的安装"},{"content":"前言 在Vue3项目中,如果我们想上传图片一般可以利用element-ui中的el-upload,为了避免代码的重复,我们可以自己封装一个图片上传组件。\n其中,主要实现思想为前端利用el-upload组件选择上传的图片,并利用其http-request属性来自定义函数来实现文件上传请求:该请求函数使用七牛云的对象存储,在通过后端得到的上传凭证token后来实现文件上传。\n后端代码 使用express框架,获取七牛云上传凭证并响应给前端\n项目结构 - routes |- token.js |- index.js - app.js - config.js - package.json 安装七牛云的SDK: npm i qiniu 获取上传凭证 编写获取上传凭证的相关代码:\n/* config.js */ const qiniu = require(\u0026#39;qiniu\u0026#39;) // 创建上传凭证 const accessKey = \u0026#39;*****\u0026#39; // 这里填写七牛云的accessKey const secretKey = \u0026#39;*****\u0026#39;// 这里填写七牛云的secretKey const mac = new qiniu.auth.digest.Mac(accessKey, secretKey) const options = { scope: \u0026#39;*****\u0026#39;, // 这里填写七牛云空间名称 expires: 60 * 60 * 24 * 7 // 这里是凭证的有效时间,默认是一小时 } const putPolicy = new qiniu.rs.PutPolicy(options) const uploadToken = putPolicy.uploadToken(mac) module.exports = { uploadToken } 配置路由 token.js\nconst tokenRouter = require(\u0026#39;express\u0026#39;).Router() const qnconfig = require(\u0026#39;../config\u0026#39;) // 引入七牛云配置 tokenRouter.get(\u0026#39;/qiniu\u0026#39;, (req, res, next) =\u0026gt; { res.status(200).send(qnconfig.uploadToken) }) module.exports = tokenRouter index.js\nconst token = require(\u0026#39;./token\u0026#39;) module.exports = routes = (app) =\u0026gt; { app.use(\u0026#39;/token\u0026#39;, token) // 可以通过/token/qiniu的方式获取上传凭证 } 项目启动 const express = require(\u0026#39;express\u0026#39;) const bodyparse = require(\u0026#39;body-parser\u0026#39;) const routers = require(\u0026#39;./route\u0026#39;) // 创建服务 const app = express() // 解析数据 app.use(bodyparse.json()) // 路由 routes(app) // 监听3000端口 app.listen(3000, () =\u0026gt; { console.log(\u0026#39;this server are running on localhost:3000!\u0026#39;) }) 使用命令node app.js启动项目,这时访问http://localhost:3000/token/qiniu即可获取上传凭证了。\n前端代码 配置跨域 由于前后端项目运行在不同的端口,因此需要解决跨域问题,这里在vite.config.js中解决如下:\nserver: { proxy: { \u0026#39;/api\u0026#39;: { target: \u0026#39;http://localhost:3000\u0026#39;, changeOrigin: true, rewrite: (path) =\u0026gt; path.replace(/^/api/, \u0026#39;\u0026#39;) } } } 父组件使用 我们希望子组件上传图片得到一串url后父组件能接受到,并且在展示上传图片时其尺寸应能指定或者有默认值。\n\u0026lt;template\u0026gt; \u0026lt;Upload :url=\u0026#34;imageUrl\u0026#34; @upload=\u0026#34;changeUrl\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import Upload from \u0026#39;@/components/Upload.vue\u0026#39; import { ref } from \u0026#39;vue\u0026#39; const imageUrl = ref(\u0026#39;\u0026#39;) const changeUrl = (url) =\u0026gt; { imageUrl.value = url } \u0026lt;/script\u0026gt; 封装组件Upload.vue 这里只是简单使用axios,没有对其进行封装。\n\u0026lt;template\u0026gt; \u0026lt;!- action=\u0026#34;https://upload-z2.qiniup.com\u0026#34;:每个地区访问域名不同,具体可通过 https://developer.qiniu.com/kodo/1671/region-endpoint-fq 查看 -\u0026gt;\t\u0026lt;el-upload class=\u0026#34;avatar-uploader\u0026#34; action=\u0026#34;https://upload-z2.qiniup.com\u0026#34; :show-file-list=\u0026#34;false\u0026#34; :http-request=\u0026#34;up2qiniu\u0026#34; :before-upload=\u0026#34;beforeUpload\u0026#34; \u0026gt; \u0026lt;img v-if=\u0026#34;props.url\u0026#34; :src=\u0026#34;props.url\u0026#34; class=\u0026#34;avatar\u0026#34; :style=\u0026#34;\u0026#39;width: \u0026#39; + props.width + \u0026#39;px;\u0026#39; + \u0026#39;height: \u0026#39; + props.height + \u0026#39;px;\u0026#39;\u0026#34; /\u0026gt; \u0026lt;el-icon v-else class=\u0026#34;avatar-uploader-icon\u0026#34; :style=\u0026#34;\u0026#39;width: \u0026#39; + props.width + \u0026#39;px;\u0026#39; + \u0026#39;height: \u0026#39; + props.height + \u0026#39;px;\u0026#39;\u0026#34; \u0026gt;\u0026lt;Plus /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;/el-upload\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref } from \u0026#39;vue\u0026#39; import { getQiniuToken } from \u0026#39;../api/token\u0026#39; import axios from \u0026#39;axios\u0026#39; import { ElMessage } from \u0026#39;element-plus\u0026#39; const qiniuaddr = \u0026#39;rlr92qkze.hn-bkt.clouddn.com\u0026#39; // 这里是七牛云存储对象中的CDN域名 const imageUrl = ref(\u0026#39;\u0026#39;) // 父组件传值时,须有图片的url;其次可选择图片的宽高(默认都为180) const props = defineProps({ url: String, width: { type: Number, default: 180 }, height: { type: Number, default: 180 } }) const emit = defineEmits([\u0026#39;upload\u0026#39;]) const beforeUpload = (rawFile) =\u0026gt; { if (rawFile.type !== \u0026#39;image/jpg\u0026#39; \u0026amp;\u0026amp; rawFile.type !== \u0026#39;image/png\u0026#39;) { ElMessage.error(\u0026#39;图片格式应该是png或jpg\u0026#39;) return false } else if (rawFile.size / 1024 / 1024 \u0026gt; 2) { ElMessage.error(\u0026#39;图片大小应该小于2MB\u0026#39;) return false } return true } /** * 上传图片至七牛云 * @param {*} req */ const up2qiniu = (req) =\u0026gt; { const config = { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;multipart/form-data\u0026#39; } } const fileType = req.file.type === \u0026#39;image/png\u0026#39; ? \u0026#39;png\u0026#39; : \u0026#39;jpg\u0026#39; // 重命名要上传的文件 const keyname = \u0026#39;blog\u0026#39; + new Date().getTime() + \u0026#39;.\u0026#39; + fileType axios.get(\u0026#39;/api/token/qiniu\u0026#39;).then(res =\u0026gt; { const formdata = new FormData() formdata.append(\u0026#39;file\u0026#39;, req.file) formdata.append(\u0026#39;token\u0026#39;, res.data) formdata.append(\u0026#39;key\u0026#39;, keyname) // 获取到凭证之后再将文件上传到七牛云空间 axios.post(\u0026#39;https://upload-z2.qiniup.com\u0026#39;, formdata, config).then((res) =\u0026gt; { imageUrl.value = \u0026#39;http://\u0026#39; + qiniuaddr + \u0026#39;/\u0026#39; + res.data.key emit(\u0026#39;upload\u0026#39;, imageUrl.value) // 向父组件传递图片的url }) }) } \u0026lt;/script\u0026gt; \u0026lt;style lang=\u0026#34;scss\u0026#34; scoped\u0026gt; .avatar-uploader .avatar { width: 360px; height: 180px; display: block; } .avatar-uploader :deep(.el-upload) { border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; transition: var(--el-transition-duration-fast); } .avatar-uploader :deep(.el-upload:hover) { border-color: var(--el-color-primary); } .el-icon.avatar-uploader-icon { font-size: 28px; color: #8c939d; width: 360px; height: 180px; text-align: center; } \u0026lt;/style\u0026gt; ","permalink":"https://kerolt.github.io/posts/%E5%89%8D%E7%AB%AF/vue3%E5%B0%81%E8%A3%85el-upload/","summary":"\u003ch2 id=\"前言\"\u003e前言\u003c/h2\u003e\n\u003cp\u003e在Vue3项目中,如果我们想上传图片一般可以利用element-ui中的\u003ccode\u003eel-upload\u003c/code\u003e,为了避免代码的重复,我们可以自己封装一个图片上传组件。\u003c/p\u003e\n\u003cp\u003e其中,主要实现思想为前端利用el-upload组件选择上传的图片,并利用其\u003ccode\u003ehttp-request\u003c/code\u003e属性来自定义函数来实现文件上传请求:该请求函数使用七牛云的对象存储,在通过后端得到的上传凭证token后来实现文件上传。\u003c/p\u003e","title":"Vue3封装el-upload"},{"content":" https://leetcode.cn/problems/palindrome-linked-list/\n(1)将链表转化为数组进行比较 比较呆板的做法,空间复杂度为O(n)。\nclass Solution { public: bool isPalindrome(ListNode* head) { vector\u0026lt;int\u0026gt; arr; ListNode* p = head; while (p) { arr.push_back(p-\u0026gt;val); p = p-\u0026gt;next; } int n = arr.size(); for (int i = 0, j = n - 1; i \u0026lt; j; i++, j--) { if (arr[i] != arr[j]) return false; } return true; } }; (2)递归 链表也具有递归性质,二叉树也不过是链表的衍生。\n利用后序遍历的思想:\n先保存头结点(left,全局变量),然后递归至最后(最深)的结点(right),然后比较left和right的值;如果相等,由递归栈返回上一层(也即right向左走),再操作left向右走,这样就实现了left和right的双向奔赴。\nclass Solution { private: ListNode* left_ = nullptr; bool Traverse(ListNode* right) { if (!right) return true; bool res = Traverse(right-\u0026gt;next); res = res \u0026amp;\u0026amp; (left_-\u0026gt;val == right-\u0026gt;val); left_ = left_-\u0026gt;next; return res; } public: bool isPalindrome(ListNode* head) { left_ = head; return Traverse(head-\u0026gt;next); } }; (3)优化递归 利用方法二,看似是没有使用到额外空间了,但实际上还有递归所带来的函数调用栈的开销,其空间复杂度也为O(n)。\n因此可以利用双指针的思想,找到链表的中间结点后,将其后面的结点反转。\nusing ListNodePtr = ListNode*; class Solution { private: ListNode* Reverse(ListNode* head) { ListNodePtr cur = head, pre = nullptr; while (cur) { ListNodePtr ne = cur-\u0026gt;next; cur-\u0026gt;next = pre; pre = cur; cur = ne; } return pre; } public: bool isPalindrome(ListNode* head) { ListNodePtr fast = head, slow = head; while (fast \u0026amp;\u0026amp; fast-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; } if (fast) slow = slow-\u0026gt;next; ListNodePtr left = head; ListNodePtr right = Reverse(slow); while (right) { if (left-\u0026gt;val != right-\u0026gt;val) return false; left = left-\u0026gt;next; right = right-\u0026gt;next; } return true; } }; ","permalink":"https://kerolt.github.io/posts/%E7%AE%97%E6%B3%95/%E9%93%BE%E8%A1%A8%E5%88%A4%E6%96%AD%E5%9B%9E%E6%96%87%E9%93%BE%E8%A1%A8/","summary":"https://leetcode.cn/problems/palindrome-linked-list/\n(1)将链表转化为数组进行比较 比较呆板的做法,空间复杂度为O(n)。\nclass Solution { public: bool isPalindrome(ListNode* head) { vector\u0026lt;int\u0026gt; arr; ListNode* p = head; while (p) { arr.push_back(p-\u0026gt;val); p = p-\u0026gt;next; } int n = arr.size(); for (int i = 0, j = n - 1; i \u0026lt; j; i++, j--) { if (arr[i] != arr[j]) return false; } return true; } }; (2)递归 链表也具有递归性质,二叉树也不过是链表的衍生。\n利用后序遍历的思想:\n先保存头结点(left,全局变量),然后递归至最后(最深)的结点(right),然后比较left和right的值;如果相等,由递归栈返回上一层(也即right向左走),再操作left向右走,这样就实现了left和right的双向奔赴。\nclass Solution { private: ListNode* left_ = nullptr; bool Traverse(ListNode* right) { if (!","title":"【链表】判断回文链表"},{"content":"Hi there 👋, I am Kerolt! 😁 你好,我是Kerolt!喜欢学习点计算机底层、Web等技术,此外,我也热爱于羽毛球🏸和科幻作品。\n💬 Brainstorm with me over tech, career, badminton and science fiction 📫 How to reach me: [email protected] What I love 羽毛球🏸 跑步🏃 健身 科幻 动漫:巨人、EVA、漂流少年… ","permalink":"https://kerolt.github.io/about/","summary":"about","title":"关于我"},{"content":"项目文档 开篇词|埋点SDK项目介绍\n1|开发环境\n2|流程设计\n3|代码结构设计\n4|三方库依赖\n5|对外接口设计\n6|日志模块实现\n7|上报协议设计\n8|数据库设计与使用\n9|如何发起 Http 请求\n10|加解密实现\n11|多线程开发\n12|上报模块实现\n13|公共信息获取\n14|整体功能组装\n15|结束语\n埋点 SDK 的介绍 埋点SDK是一种用于数据采集和分析的软件开发工具包(SDK)。它帮助开发者在应用程序中集成数据追踪功能,用于收集用户行为数据、设备信息、业务事件等内容。埋点通常被广泛应用于移动应用、Web应用以及其他需要行为分析的场景,常见用途包括用户行为分析、广告转化率跟踪、异常监控等。\n埋点的分类 埋点主要分为以下几种形式:\n手动埋点: 开发者在代码中手动添加埋点代码来记录特定事件或行为。 优点:灵活性高,适合复杂逻辑的事件。 缺点:开发维护成本较高,容易遗漏或出错。 可视化埋点: 通过图形化界面在前端应用的可视区域(如按钮、页面等)直接配置埋点。 优点:无需修改代码,非技术人员也可操作。 缺点:复杂事件可能无法实现。 无埋点(全埋点): 在应用程序中植入通用采集代码,自动记录所有用户行为(如点击、滑动、页面停留时间等)。 优点:无需提前规划,数据采集全面。 缺点:数据量大,后期分析成本较高,需要配合服务端进行数据过滤和清洗。 埋点SDK的功能 事件采集: 记录用户在应用中触发的事件(如点击按钮、页面浏览)。 设备信息采集: 收集设备ID、操作系统、网络状态等环境信息。 数据缓存与上传: 数据可以本地存储,并定期或实时上传到服务器。 数据加密: 确保传输过程中的数据安全性,防止泄露。 异常监控: 捕获崩溃日志或错误信息,帮助开发者定位问题。 应用场景 用户行为分析:追踪用户路径、页面停留时间、点击热点等。 业务转化分析:监控转化漏斗,优化转化率。 性能监控:监控加载时间、崩溃率等关键性能指标。 广告效果评估:跟踪广告点击、下载、转化情况。 A/B测试:支持数据采集以便评估不同实验组的表现。 ","permalink":"https://kerolt.github.io/drafts/%E5%9F%8B%E7%82%B9sdk/","summary":"项目文档 开篇词|埋点SDK项目介绍\n1|开发环境\n2|流程设计\n3|代码结构设计\n4|三方库依赖\n5|对外接口设计\n6|日志模块实现\n7|上报协议设计\n8|数据库设计与使用\n9|如何发起 Http 请求\n10|加解密实现\n11|多线程开发\n12|上报模块实现\n13|公共信息获取\n14|整体功能组装\n15|结束语\n埋点 SDK 的介绍 埋点SDK是一种用于数据采集和分析的软件开发工具包(SDK)。它帮助开发者在应用程序中集成数据追踪功能,用于收集用户行为数据、设备信息、业务事件等内容。埋点通常被广泛应用于移动应用、Web应用以及其他需要行为分析的场景,常见用途包括用户行为分析、广告转化率跟踪、异常监控等。\n埋点的分类 埋点主要分为以下几种形式:\n手动埋点: 开发者在代码中手动添加埋点代码来记录特定事件或行为。 优点:灵活性高,适合复杂逻辑的事件。 缺点:开发维护成本较高,容易遗漏或出错。 可视化埋点: 通过图形化界面在前端应用的可视区域(如按钮、页面等)直接配置埋点。 优点:无需修改代码,非技术人员也可操作。 缺点:复杂事件可能无法实现。 无埋点(全埋点): 在应用程序中植入通用采集代码,自动记录所有用户行为(如点击、滑动、页面停留时间等)。 优点:无需提前规划,数据采集全面。 缺点:数据量大,后期分析成本较高,需要配合服务端进行数据过滤和清洗。 埋点SDK的功能 事件采集: 记录用户在应用中触发的事件(如点击按钮、页面浏览)。 设备信息采集: 收集设备ID、操作系统、网络状态等环境信息。 数据缓存与上传: 数据可以本地存储,并定期或实时上传到服务器。 数据加密: 确保传输过程中的数据安全性,防止泄露。 异常监控: 捕获崩溃日志或错误信息,帮助开发者定位问题。 应用场景 用户行为分析:追踪用户路径、页面停留时间、点击热点等。 业务转化分析:监控转化漏斗,优化转化率。 性能监控:监控加载时间、崩溃率等关键性能指标。 广告效果评估:跟踪广告点击、下载、转化情况。 A/B测试:支持数据采集以便评估不同实验组的表现。 ","title":"埋点SDK"}]