介绍

BlackCloud 的博客

Powered by mdbook

Benchmarks

记录一下经常用到的算法/容器的 Benchmark (主要使用 C++)

随用随测, 可能不会特别准确, 工作侧重于在特定负载下选择最好的算法/容器, 所以主要记录不同选择的相对性能

Latency Numbers Every Programmer Should Know

Reference: Latency Numbers Every Programmer Should Know

Latency Comparison Numbers (~2012)
----------------------------------
L1 cache reference                           0.5 ns
Branch mispredict                            5   ns
L2 cache reference                           7   ns                      14x L1 cache
Mutex lock/unlock                           25   ns
Main memory reference                      100   ns                      20x L2 cache, 200x L1 cache
Compress 1K bytes with Zippy             3,000   ns        3 us
Send 1K bytes over 1 Gbps network       10,000   ns       10 us
Read 4K randomly from SSD*             150,000   ns      150 us          ~1GB/sec SSD
Read 1 MB sequentially from memory     250,000   ns      250 us
Round trip within same datacenter      500,000   ns      500 us
Read 1 MB sequentially from SSD*     1,000,000   ns    1,000 us    1 ms  ~1GB/sec SSD, 4X memory
Disk seek                           10,000,000   ns   10,000 us   10 ms  20x datacenter roundtrip
Read 1 MB sequentially from disk    20,000,000   ns   20,000 us   20 ms  80x memory, 20X SSD
Send packet CA->Netherlands->CA    150,000,000   ns  150,000 us  150 ms

Notes
-----
1 ns = 10^-9 seconds
1 us = 10^-6 seconds = 1,000 ns
1 ms = 10^-3 seconds = 1,000 us = 1,000,000 ns

Credit
------
By Jeff Dean:               http://research.google.com/people/jeff/
Originally by Peter Norvig: http://norvig.com/21-days.html#answers

Contributions
-------------
'Humanized' comparison:  https://gist.github.com/hellerbarde/2843375
Visual comparison chart: http://i.imgur.com/k0t1e.png

String Hash

  • 4-16 个字符的短字符串
|               ns/op |                op/s |    err% |          ins/op |         bra/op |   miss% |     total | benchmark
|--------------------:|--------------------:|--------:|----------------:|---------------:|--------:|----------:|:----------
|       11,094,427.00 |               90.14 |    0.4% |  111,833,763.00 |  12,383,248.00 |    4.0% |      0.12 | `CRC32`
|        8,181,207.00 |              122.23 |    3.2% |   81,658,267.00 |  12,940,438.00 |    3.8% |      0.09 | `FNV`
|        5,397,684.00 |              185.26 |    0.4% |   56,646,296.00 |   9,432,868.00 |    5.3% |      0.06 | `Murmur2`
|        6,749,671.00 |              148.16 |    1.2% |   67,046,257.00 |   9,432,870.00 |    5.3% |      0.08 | `Murmur3_x86_32`
|       11,833,767.00 |               84.50 |    0.5% |  128,028,092.00 |   5,949,857.00 |    8.5% |      0.13 | `Murmur3_x86_128`
|        7,636,166.00 |              130.96 |    0.8% |   91,747,270.00 |   6,441,582.00 |    7.7% |      0.09 | `Murmur3_x64_128`
  • ~200 个字符的中等长度字符串
|               ns/op |                op/s |    err% |          ins/op |         bra/op |   miss% |     total | benchmark
|--------------------:|--------------------:|--------:|----------------:|---------------:|--------:|----------:|:----------
|      134,931,464.00 |                7.41 |    0.0% |1,235,797,487.00 |  80,973,317.00 |    1.2% |      1.48 | `CRC32`
|      154,305,226.00 |                6.48 |    0.0% |1,250,489,432.00 | 179,927,192.00 |    0.6% |      1.70 | `FNV`
|       41,743,783.00 |               23.96 |    0.0% |  474,381,552.00 |  50,949,403.00 |    1.8% |      0.46 | `Murmur2`
|       44,541,974.00 |               22.45 |    0.1% |  446,744,071.00 |  51,629,173.00 |    1.8% |      0.49 | `Murmur3_x86_32`
|       44,402,780.00 |               22.52 |    0.1% |  461,885,844.00 |  16,677,849.00 |    5.3% |      0.49 | `Murmur3_x86_128`
|       26,934,988.00 |               37.13 |    0.2% |  307,733,470.00 |  16,584,743.00 |    5.3% |      0.30 | `Murmur3_x64_128`

Map

  • 完全随机的 int key, 做 find_or_insert 操作
|               ns/op |                op/s |    err% |          ins/op |         bra/op |   miss% |     total | benchmark
|--------------------:|--------------------:|--------:|----------------:|---------------:|--------:|----------:|:----------
|       35,153,700.00 |               28.45 |    1.8% |  157,273,534.00 |  33,488,895.00 |    1.5% |      0.41 | `unordered_map:random`
|      136,277,935.00 |                7.34 |    1.7% |  193,896,493.00 |  48,073,497.00 |   13.1% |      1.49 | `map:random`
|       58,396,319.00 |               17.12 |    0.8% |  312,029,741.00 |  92,169,201.00 |    4.9% |      0.66 | `btree_map:random`
|       12,382,928.00 |               80.76 |    0.8% |   92,646,655.00 |  12,036,977.00 |    5.7% |      0.14 | `robin_hood_map:random`
|       17,489,723.00 |               57.18 |    1.2% |   90,160,012.00 |  17,922,242.00 |    4.4% |      0.20 | `dense_hash_map:random`
|          810,089.00 |            1,234.43 |    3.2% |    3,600,629.00 |     300,147.00 |    0.0% |      0.01 | `array(test):random`
  • 顺序出现的 int key, 每个 key 重复出现 32 次, 组与组之间略微打乱(比如 0 0 0 1 0 1 1 2 1 2 2 2 ... 这样的序列), 做 find_or_insert 操作
|               ns/op |                op/s |    err% |          ins/op |         bra/op |   miss% |     total | benchmark
|--------------------:|--------------------:|--------:|----------------:|---------------:|--------:|----------:|:----------
|       19,428,866.00 |               51.47 |    1.2% |  137,639,829.00 |  26,979,609.00 |    1.2% |      0.22 | `unordered_map:sequential`
|      305,547,283.00 |                3.27 |    0.5% |  494,128,064.00 | 130,782,008.00 |   17.3% |      3.34 | `map:sequential`
|      183,810,512.00 |                5.44 |    0.4% |  973,906,327.00 | 302,459,238.00 |    4.8% |      2.03 | `btree_map:sequential`
|       34,349,617.00 |               29.11 |    0.3% |  183,005,851.00 |  24,127,612.00 |   12.6% |      0.38 | `robin_hood_map:sequential`
|       26,675,597.00 |               37.49 |    1.2% |  185,300,938.00 |  46,039,048.00 |    5.1% |      0.30 | `dense_hash_map:sequential`
|        6,950,046.00 |              143.88 |    0.8% |   38,400,593.00 |   3,200,143.00 |    0.0% |      0.08 | `array(test):sequential`
  • 查找短字符串(例如交易对字符串), 期望使用 std::string_view 来查找(例如解析 json 时)
|               ns/op |                op/s |    err% |     total | AliasMap
|--------------------:|--------------------:|--------:|----------:|:---------
|       15,420,964.00 |               64.85 |    0.2% |      0.17 | `std::map<std::string, val>`
|        9,693,194.00 |              103.17 |    0.1% |      0.11 | `std::map<uint64_t, val> (with murmurhash64)`
|        2,988,340.00 |              334.63 |    0.4% |      0.03 | `std::unordered_map<uint64_t, val> (with murmurhash64)`
|        3,005,004.00 |              332.78 |    0.4% |      0.03 | `robin_hood::unordered_map<uint64_t, val> (with murmurhash64)`

simdjson

find_field 方式对比

simdjson::ondemand 模型里, 可以通过 find_field, find_field_unorderedoperator[] 来查字段, find_field 要求有序访问, 后两者则可以无序

简单测试性能

结果(O2)

|               ns/op |                op/s |    err% |     total | benchmark
|--------------------:|--------------------:|--------:|----------:|:----------
|              118.30 |        8,452,987.49 |    0.6% |      0.01 | `find_in_order`
|              127.27 |        7,857,332.54 |    1.0% |      0.01 | `find_unordered_in_order`
|              217.35 |        4,600,790.26 |    0.5% |      0.01 | `find_unordered_unordered`
|              130.00 |        7,692,027.51 |    1.2% |      0.01 | `operator [] in order`
|              219.08 |        4,564,583.22 |    0.8% |      0.01 | `operator [] unordered`

结论

  • 在有序访问的前提下, find_field 最快, 但和 find_field_unordered 差距不大
  • 无序访问导致 rewind 后开销会近乎翻倍(大概就是重新遍历的代价)
  • operator[]find_field_unordered 差不多

代码

#define ANKERL_NANOBENCH_IMPLEMENT
#include "nanobench.h"
#include "simdjson.h"

auto json = R"({"e":"ORDER_TRADE_UPDATE","T":1723542730357,"E":1723542730357,"o":{"s":"ADAUSDT","c":"15570553482120512","S":"BUY","o":"LIMIT","f":"GTX","q":"900","p":"0.33210","ap":"0","sp":"0","x":"CANCELED","X":"CANCELED","i":43631626214,"l":"0","z":"0","L":"0","n":"0","N":"USDT","T":1723542730357,"t":0,"b":"598.14520","a":"301.11419","m":false,"R":false,"wt":"CONTRACT_PRICE","ot":"LIMIT","ps":"BOTH","cp":false,"rp":"0","pP":false,"si":0,"ss":0,"V":"EXPIRE_MAKER","pm":"NONE","gtd":0}})"_padded;

int main()
{
    simdjson::ondemand::parser parser;

    ankerl::nanobench::Bench().run("find_in_order", [&](){
        auto doc = parser.iterate(json);

        std::string_view e = doc.find_field("e");
        assert(e == "ORDER_TRADE_UPDATE");

        auto o = doc.find_field("o").get_object();

        std::string_view s = o.find_field("s");
        assert(s == "ADAUSDT");

        double q = o.find_field("q").get_double_in_string();
        assert(q == 900);       
    });

    ankerl::nanobench::Bench().run("find_unordered_in_order", [&](){
        auto doc = parser.iterate(json);

        std::string_view e = doc.find_field_unordered("e");
        assert(e == "ORDER_TRADE_UPDATE");

        auto o = doc.find_field_unordered("o").get_object();

        std::string_view s = o.find_field_unordered("s");
        assert(s == "ADAUSDT");

        double q = o.find_field_unordered("q").get_double_in_string();
        assert(q == 900);       
    });

    ankerl::nanobench::Bench().run("find_unordered_unordered", [&](){
        auto doc = parser.iterate(json);

        std::string_view e = doc.find_field_unordered("e");
        assert(e == "ORDER_TRADE_UPDATE");

        auto o = doc.find_field_unordered("o").get_object();

        double q = o.find_field_unordered("q").get_double_in_string();
        assert(q == 900);       

        std::string_view s = o.find_field_unordered("s");
        assert(s == "ADAUSDT");
    });

    ankerl::nanobench::Bench().run("operator [] in order", [&](){
        auto doc = parser.iterate(json);

        std::string_view e = doc["e"];
        assert(e == "ORDER_TRADE_UPDATE");

        auto o = doc["o"];

        std::string_view s = o["s"];

        double q = o["q"].get_double_in_string();

        assert(s == "ADAUSDT");

        assert(q == 900);
    });

    ankerl::nanobench::Bench().run("operator [] unordered", [&](){
        auto doc = parser.iterate(json);

        std::string_view e = doc["e"];
        assert(e == "ORDER_TRADE_UPDATE");

        auto o = doc["o"];

        double q = o["q"].get_double_in_string();

        std::string_view s = o["s"];

        assert(s == "ADAUSDT");

        assert(q == 900);
    });

    return 0;
}

Idioms

1. CRTP

我想实现如下伪代码所示的编程模式, 其中 Host 代表 C/S 模型中的任何一个端点, ServerClient 都需要处理收到的包, 但对于每个类型的包, 它们有不同的处理方式. 收包的逻辑是相同的, 因此我希望复用 handle_packet. 最简单的方法就是用虚函数

class Host {
    void handle_packet(...) {
        // do receive packet
        switch (packet_type) {
        case (a):
            handle_packet_a();
            break;
        case (b):
            handle_packet_b();
            break;
        };
    };
    virtual void handle_packet_a();
    virtual void handle_packet_b();
};

class Server: public Host {
    virtual void handle_packet_a() override;
    virtual void handle_packet_b() override;
};

class Client: public Host {
    virtual void handle_packet_a() override;
    virtual void handle_packet_b() override;
};

但这样设计只是为了复用 handle_packet 的逻辑, 而没有动态绑定的需求, 因此引入虚函数增加了没有必要的开销

这种编译期多态可以通过 CRTP 实现

template<typename Derived>
class Host {
    void handle_packet() {
        switch (packet_type) {
        case (a):
            static_cast<Derived&>(*this).handle_packet_a();
            break;
        case (b):
            static_cast<Derived&>(*this).handle_packet_b();
            break;
        };
    }
};

class Server: public Host<Server> {
    void handle_packet_a();
    void handle_packet_b();
};
// ...

这样可以完全去除虚函数

2. SFINAE

Substitution Failure Is Not An Error

起因是想在用 CRTP 时实现编译时检测子类是否具有某个方法, 仅当有方法时才调用的需求, 例如:

template<typename D>
class Animal {
    void live() {
        while (true) {
            static_cast<D&>(*this).eat();
            static_cast<D&>(*this).sleep();
            static_cast<D&>(*this).bark(); // compile error! Cat will never bark
            // Want: if constexpr (D has member `bark`) then call `bark`
        }
    }
};

class Cat: public Animal<Cat> {
    void eat();
    void sleep();
};

class Dog: public Animal<Dog> {
    void eat();
    void sleep();
    void bark();
};

这种在编译期模板实例化时确定模板参数是否有某种性质的行为被称为内省(introspection)

包含模板的重载函数的候选集中的某些(或者全部)候选函数来自 模板实参替换模板形参 的模板实例化结果, 在这个过程中, 某些模板的实参替换可能会失败, 这种替换失败(Substitution Failure)并不会立即被当作编译错误(Error)抛出, 这个替换失败的模板会被从候选集中删除, 只要到最后存在成功的替换, 即重载函数候选集不为空, 则这个重载函数的解析就是成功的, 编译也能通过

见来自 替换失败并非错误 的例子:

struct Test {
  typedef int foo;
};

template <typename T>
void f(typename T::foo) {}  // Definition #1

template <typename T>
void f(T) {}  // Definition #2

int main() {
  f<Test>(10);  // Call #1.
  f<int>(10);   // Call #2. 并无编译错误(即使没有 int::foo)
                // thanks to SFINAE.
}

在编译时, f<Test>(10) 会针对 f 的两个定义做两次 Substitution

  1. 第一次替换得到的函数定义是 void f(typename Test::foo) {}
  2. 第二次替换得到的函数定义是 void f(Test) {} 因此这个调用拥有两个可能的候选, 而根据实参 10 的类型可以推导得到只有 1. 符合要求, 因此最终会调用 1., 编译通过

f<int>(10) 也会针对 f 的两个定义做两次 Substitution

  1. 得到 void f(typename int::foo) {}, 但 int::foo 并不存在, 因此这个替换失败了, 这个函数并不会进入候选集
  2. 得到 void f(int) {} 因此这个调用拥有一个可能的候选, 而实参 10 的类型可以匹配这个唯一的候选, 因此最终会调用 2., 编译通过

上面的例子里, SFINAE 恰好干了在开头时我想干的事: 在编译时判断一个成员是否存在于 struct/class 中.

经过修改可以得到以下真正实现了这个需求的代码, 检查类型 T 上是否有拥有 bark 成员函数:

template<typename T>
struct has_member_bark {
    private:
        template<typename U> static auto check(int) 
            -> decltype(std::declval<U>().bark(), std::true_type());
        template<typename U> static std::false_type check(...);
    public:
        enum {value = std::is_same<decltype(check<T>(0)), std::true_type>::value};
};

// 接上面 Animal 的例子...
if constexpr (has_member_bark<D>::value) {
    static_cast<D&>(*this).bark();
}

上述例子工作的原理是

  1. 编译时要对 has_member_bark<T>::value 求值, 其值等于 std::is_same<decltype(check<T>(0)), std::true_type>::value

  2. 需要推导 decltype(check<T>(0))

  3. check<T>(0) 有两个可选的模板

    • template<typename U> static auto check(int) -> decltype(std::declval<U>().bark(), std::true_type())
    • template<typename U> static std::false_type check(...)

    其中后者由于指定了 ... 作为参数, 因此拥有最低的匹配优先级, 所以编译器会优先尝试第一个定义

    第一个定义的返回值需要被推导, 其类型为

    decltype(std::declval<U>().bark(), std::true_type())
    

    decltype 内是一个 comma expr, 其值等于最后一个表达式的值, 但是求值是从前到后进行的, 因此必须先推导 std::declval<U>().bark() 的类型, 这时

    • 如果 U::bark 不存在, 则这个替换就会失败, 因此 check 的第一个模板就会被删除, 只留下第二个, 则 decltype(check<T>(0))false_type
    • 如果 U::bark 存在, 则这个替换成功, check 的第一个模板成为最终的选择, decltype(check<T>(0))true_type
  4. 无论 T::bark 是否存在, 由于 SFINAE, 最终总有一个 check 被匹配, 如果 T::bark 存在, 则 value 最终为 true_type, 否则是 false_type

一个完整的可编译的例子:

#include <iostream>

template<typename T>
struct has_member_bark {
    private:
        template<typename U> static auto check(int) 
            -> decltype(std::declval<U>().bark(), std::true_type());
        template<typename U> static std::false_type check(...);
    public:
        enum {value = std::is_same<decltype(check<T>(0)), std::true_type>::value};
};

template<typename D>
class Animal {
public:
    void live() {
        // while (true) {
            static_cast<D&>(*this).eat();
            static_cast<D&>(*this).sleep();
            if constexpr (has_member_bark<D>::value) {
                static_cast<D&>(*this).bark();   
            }
        // }
    }
};

class Cat: public Animal<Cat> {
public:
    void eat() {
        std::cout << "Cat eat" << std::endl;
    }
    void sleep() {
        std::cout << "Cat sleep" << std::endl;
    }
};

class Dog: public Animal<Dog> {
public:
    void eat() {
        std::cout << "Dog eat" << std::endl;
    }
    void sleep() {
        std::cout << "Dog sleep" << std::endl;
    }
    void bark() {
        std::cout << "Woof!" << std::endl;
    }
};

int main() {
    Cat().live();
    Dog().live();
    return 0;
}

输出

Cat eat
Cat sleep
Dog eat
Dog sleep
Woof!

3. PIMPL

Pointer to IMPLementation

写了一个库, 起初供用户 include 的头文件里有这样的声明:

class Server {
    public:
        Server();
        void start();
    private:
        void receive_packets();
        void handle_packet();
        void handle_packet_data();
        void handle_packet_ping();
        void handle_packet_pong();
    private:
        int socket;
        // more members ...
};

但首先, 暴露给用户的接口只有 start, 用户在看头文件时只需要看到他能使用的接口即可, 看到一堆其他的东西会干扰阅读, 其次暴露太多实现细节也不好

可以用 pimpl 来实现 implementation 的隐藏, 原理比较简单, 只贴代码了:

// server.h
class Server {
    public:
        Server();
        void start();
    private:
        class ServerImpl;
        std::unique_ptr<ServerImpl> impl;
};

// server.cpp
Server::Server(): impl(std::make_unique<ServerImpl>()) {}
void Server::start() {
    while (true) {
        impl->receive_packets();
    }
}

class Server::ServerImpl {
    public:
        void receive_packets() {
            // ...
        }
    private:
        void handle_packet();
        void handle_packet_data();
        void handle_packet_ping();
        void handle_packet_pong();
    private:
        int socket;
        // more members ...
}

这样所有的实现细节都被隐藏到 source file 里了

lambda, std::function, etc

1. 问题

之前写代码遇到了一些传递回调函数的需求, 例如:

receive_packet_and_handle([](const char* buf, int len){
    // do sth. with buf
});

这样 receive_packet_and_handle 有两种写法:

  1. template<typename F>
    void receive_packet_and_handle(F&& handler) {
        // ...
    }
    
  2. using handler_t = std::function<void(const char*, int)>;
    void receive_packet_and_handle(const handler_t& handler) {
        // ...
    }
    

想知道两种传参有没有性能上的不同

2. 结论

先说结论, template 风格性能通常比 std::function 风格好, 前者能 inline lambda, 后者通常不能 inline, 可能还需要构造 std::function 对象

参考 can-be-stdfunction-inlined-or-should-i-use-different-approach 的回答

std::function is not a zero-runtime-cost abstraction. It is a type-erased wrapper that has a virtual-call like cost when invoking operator() and could also potentially heap-allocate (which could mean a cache-miss per call).

The compiler will most likely not be able to inline it.

If you want to store your function objects in such a way that does not introduce additional overhead and that allows the compiler to inline it, you should use a template parameters. This is not always possible, but might fit your use case.

不过之前想用 std::function 的另一个原因是它能约束传入的 lambda 的参数及返回值, 用模板的话当传入的函数签名不符合预期时会有比较晦涩的报错, 而且函数使用者也很难直接从函数声明看出来到底要传入什么 lambda, 返回值如何, 可读性很差

对于这个问题, 查到的解决办法是用 std::invocable, 参考 how-can-i-restrict-lambda-signatures-in-c17-template-arguments

3. 汇编

在 Compiler Explorer 上用 x86-64 gcc 12.2 测试如下代码

template<typename F>
void with_tempalte(F&& f) {
    f(10);
}

void with_function(std::function<void(int x)>&& f) {
    f(10);
}

static volatile int a;
void test() {
    // #1
    with_tempalte([](int x){
        a = x;
    });

    // #2
    with_function([](int x){
        a = x;
    });
}

O0

1. 调用 with_template

lea     rax, [rbp-65]
mov     rdi, rax
call    void with_tempalte<test()::{lambda(int)#1}>(test()::{lambda(int)#1}&&)

2. 调用 with_function

lea     rdx, [rbp-17]
lea     rax, [rbp-64]
mov     rsi, rdx
mov     rdi, rax
call    std::function<void (int)>::function<test()::{lambda(int)#2}, void>(test()::{lambda(int)#2}&&)
lea     rax, [rbp-64]
mov     rdi, rax
call    with_function(std::function<void (int)>&&)
lea     rax, [rbp-64]
mov     rdi, rax
call    std::function<void (int)>::~function() [complete object destructor]
jmp     .L19
mov     rbx, rax
lea     rax, [rbp-64]
mov     rdi, rax
call    std::function<void (int)>::~function() [complete object destructor]
mov     rax, rbx
mov     rdi, rax
call    _Unwind_Resume

std::function 时会构造 std::function 对象, 后者显然更低效

O1

1.

lambda 被直接内联了, 汇编只有

mov     DWORD PTR a[rip], 10

2.

mov     rdi, rsp
call    with_function(std::function<void (int)>&&)

with_function(std::function<void (int)>&&):
        sub     rsp, 24
        mov     DWORD PTR [rsp+12], 10
        cmp     QWORD PTR [rdi+16], 0
        je      .L9
        lea     rsi, [rsp+12]
        call    [QWORD PTR [rdi+24]]
        add     rsp, 24
        ret
.L9:
        call    std::__throw_bad_function_call()

仍然存在对 std::function 对象的调用, 没有内联

O3

        mov     DWORD PTR a[rip], eax
        ret
with_function(std::function<void (int)>&&):
        sub     rsp, 24
        cmp     QWORD PTR [rdi+16], 0
        mov     DWORD PTR [rsp+12], 10
        je      .L10
        lea     rsi, [rsp+12]
        call    [QWORD PTR [rdi+24]]
        add     rsp, 24
        ret
.L10:
        call    std::__throw_bad_function_call()
test():
        mov     DWORD PTR a[rip], 10 # with_template
        mov     DWORD PTR a[rip], 10 # with_function
        ret

O3 情况下二者都被内联了, 但此时 with_function 仍然出现在了汇编结果里

malloc c++ class 而不调用构造函数引发的 segfault

背景

原本有一个纯 C 的库,其某结构体内保存了一个函数指针作为 callback

struct Foo {
    int (*output)(...);
};

为了在 C++ 中方便地使用 lambda 等,我自作主张地将其改成了

struct Foo {
    std::function<int(...)> output;
};

起初工作得很好,std::function 在替代函数指针方面非常方便。直到将代码放到另一套环境中时发现会随机 segfault,gdb 调试定位到了该回调相关的部分。一定是遇到了 Undefined Behavior 了

排查

出现问题的代码片段位置不尽相同,但是总是出现在 给该结构体的 output 成员赋值 上,如

// when creating a empty Foo instance, give it's member a default value
foo->output = std::function<int(...)>();

segfault 的 backtrace 最终总是

#0  0x0000000000404ce4 in std::_Function_base::~_Function_base (this=0x7fffffffc3c0, __in_chrg=<optimized out>)
#1  0x000000000040ca7e in std::function<int (...)>::~function()
#2  0x00000000004180df in std::function<int(...)>::operator=<::<lambda(...)> >::<lambda(...)> >(struct {...} &&) 

即调用 std::functionoperator= 赋值时,调用了某个 std::function 的析构函数,然后析构函数触发了 segfault。根据赋值的位置,应当是将新的 output 赋值到成员变量时,原本成员变量的 output 需要析构

然后想到因为是纯 C 的库,申请 Foo 的位置均使用 mallocmalloc 默认不会构造结构体及其 member 的构造函数,因此得到的 foo->output 的位置上也是未初始化的、随意一段内存,却被当成了一个合法的 std::function。在这段随意的内存上试图调用 std::function 的函数则成为了 UB,导致了 segfault

因此解决方式也很简单,在 malloc 之后手动 placement new 一下结构体(或者是单独初始化 output)即可

Foo* foo = (Foo*)malloc(sizeof(Foo));
new (&(foo->output)) std::function<int(...)>();

priority queue with updatable priority

事情是这样的,我正在维护一组会话,每个会话每隔一段时间都需要被轮询,每个会话轮询的间隔都是不等且变化的 —— 每次轮询之后,它会告诉我下次什么时候来重新轮询

当然,在那个它告诉我的 “下次轮询时间” 之前轮询它并不会有什么作用(也没有副作用),在那个时间之后轮询则会产生一点效率损失,比如会话上的包被更晚处理了,之类的

因此一开始我的实现非常粗暴:持续地以一个非常小的时间间隔 tick,每个 tick 都遍历所有的会话并轮询它们。但假设一个会话 100ms 后才需要再次轮询,我却每 5ms 都轮询一遍所有会话,显然做了许多无用功

于是我希望用优先队列来安排每个会话的轮询时间,当然,最先想到的就是 std::priority_queue

但还有一些需求无法被满足,因为会话的轮询还有一个变量:会话的下次轮询时间可能会改变

  • 假如会话 A 原本计于 100ms 后被再次轮询,但其上产生了一个新的消息(或是什么事件),则会话 A 可能需要在 10ms 后就被轮询

放到优先队列的语境里,即一个队列内元素的优先级可能会被改变,但 std::priority_queue 并不提供改变优先级的接口,事实上它也不能做到,因为它基于堆实现,想修改堆内任意一个元素的值而不破坏堆的性质,需要以 O(N) 的代价重建堆来实现,但我们期望一个 O(log n) 的更新手段

类似的需求也在 Dijkstra 算法中被用到,其解决办法在 Easiest way of using min priority queue with key update in C++ 中被提到:在 Dijkstra 算法对优先队列的需求中,可以通过 "lazy deletion" 来实现类似的功能,即不考虑更新元素的优先级,而是直接将更小优先级的元素插入队列中,新插入的元素总是会比旧元素更早被 pop,只需要加上去重逻辑,就变相 “更新” 了更新旧元素的优先级

但是在本项目中,我觉得这个方法并不适用,因为在 Dijkstra 中图的大小是 bounded 的,而会话上消息的产生是 unbounded 的,如果每次有新消息产生并要更新会话优先级时,都插入一个新元素,那 priority queue 可能会爆掉,或者产生效率问题(比如 pop 的时候需要去除大量重复元素)

因此,我希望有这样一个优先队列,它能够以某个优先级安排队列内所有元素的顺序,又可以快速地更新某个元素的优先级

实现

一个简单的实现如下

template <typename Priority, typename Val>
class UniquePriorityQueue
{
public:
    void push_or_update(const Priority &p, const Val &v)
    {
        auto vp_it = _vp_map.find(v); // O(log n)
        if (vp_it != _vp_map.end())
        { // update
            auto pv_it = _pv_map.find(vp_it->second); // O(log n)
            assert(pv_it != _pv_map.end());
            _pv_map.erase(pv_it); // O(1)

            vp_it->second = p;
            _pv_map.insert({vp_it->second, vp_it->first}); // O(log n)
        }
        else
        {
            auto [vp_it, success] = _vp_map.emplace(v, p); // O(log n)
            assert(success);
            _pv_map.insert({vp_it->second, vp_it->first}); // O(log n)
        }
    }

    void pop()
    {
        auto pv_it = _pv_map.begin();
        assert(pv_it != _pv_map.end());
        auto vp_it = _vp_map.find(pv_it->second);
        assert(vp_it != _vp_map.end());
        _pv_map.erase(pv_it);
        _vp_map.erase(vp_it);
    }

    const std::pair<const Priority&, const Val&> top() const {
        auto pv_it = _pv_map.begin();
        assert(pv_it != _pv_map.end());
        return {pv_it->first, pv_it->second};
    }

    bool empty() const {
        assert(_pv_map.size() == _vp_map.size());
        return _vp_map.empty();
    }
private:
    std::map<Priority, const Val &> _pv_map;
    std::unordered_map<Val, Priority> _vp_map;
};

之所以叫 UniquePriorityQueue,是因为队列内的 Val 是不重复的,如果一个 Val 已经存在,那么 push_or_update 的语义就是更新其优先级

各接口的复杂度为:

  • O(1): top(), empty()
  • O(log n): push_or_update(), pop()

以下是一个完整可编译的程序(C++20), 模拟了刚才所说的安排会话的例子

#include <map>
#include <unordered_map>
#include <vector>
#include <memory>
#include <cassert>
#include <random>

template <typename Priority, typename Val>
class UniquePriorityQueue
{
public:
    void push_or_update(const Priority &p, const Val &v)
    {
        auto vp_it = _vp_map.find(v);
        if (vp_it != _vp_map.end())
        { // update
            auto pv_it = _pv_map.find(vp_it->second);
            assert(pv_it != _pv_map.end());
            _pv_map.erase(pv_it);

            vp_it->second = p;
            _pv_map.insert({vp_it->second, vp_it->first});
        }
        else
        {
            auto [vp_it, success] = _vp_map.emplace(v, p);
            assert(success);
            _pv_map.insert({vp_it->second, vp_it->first});
        }
    }

    void pop()
    {
        auto pv_it = _pv_map.begin();
        assert(pv_it != _pv_map.end());
        auto vp_it = _vp_map.find(pv_it->second);
        assert(vp_it != _vp_map.end());
        _pv_map.erase(pv_it);
        _vp_map.erase(vp_it);
    }

    const std::pair<const Priority&, const Val&> top() const {
        auto pv_it = _pv_map.begin();
        assert(pv_it != _pv_map.end());
        return {pv_it->first, pv_it->second};
    }

    bool empty() const {
        assert(_pv_map.size() == _vp_map.size());
        return _vp_map.empty();
    }
private:
    std::map<Priority, const Val &> _pv_map;
    std::unordered_map<Val, Priority> _vp_map;
};

struct Job
{
    int id;
    int i = 0;
    std::vector<int> schedule;

    Job(int id) : id(id) {}

    int next()
    {
        if (i < schedule.size())
        {
            return schedule[i++];
        }
        else
        {
            return INT32_MAX;
        }
    }
};

constexpr int JOBS = 1000;
constexpr int ITERATIONS = 1000000;

int main()
{
    std::srand(time(nullptr));

    UniquePriorityQueue<int, std::shared_ptr<Job>> q;

    std::vector<std::shared_ptr<Job>> jobs;
    std::vector<Job *> schedule;

    // generate N jobs
    for (int i = 0; i < JOBS; i++)
    {
        jobs.push_back(std::make_shared<Job>(i));
    }

    // tick ITERATIONS times, each tick one random job should be done, use `schedule` to record the order
    for (int priority = 0; priority < ITERATIONS; priority++)
    {
        auto job = jobs[std::rand() % JOBS];
        schedule.push_back(job.get());

        job->schedule.push_back(priority);
    }

    // init priority queue
    for (auto& job: jobs)
    {
        auto next = job->next();
        q.push_or_update(next, job);
    }

    // tick, each tick get the job with the lowest priority, and update its priority
    for (int i = 0; i < ITERATIONS; i++)
    {
        printf("\rticking %d/%d", i, ITERATIONS);
        const auto &[p, job] = q.top();

        // check if the order is correct
        assert(p == i);
        assert(job.get() == schedule[i]);

        // schedule next
        auto next = job->next();
        assert(next > p);

        q.push_or_update(next, job);
    }
    assert(q.top().first == INT32_MAX);
    return 0;
}

std::atomic memory_order 含义

主要参考并翻译自 Memory model synchronization modes

Sequentially Consistent

严格的顺序执行,不知道如何描述,看例子

 -Thread 1-       -Thread 2-
 y = 1            if (x.load() == 2)
 x.store (2);        assert (y == 1)

上例中如果 T2 if 触发,即它看到了 T1 的 x.store(2) 动作,那么可以保证 T1 已经完成了 y 的赋值

             a = 0
             y = 0
             b = 1
 -Thread 1-              -Thread 2-
 x = a.load()            while (y.load() != b)
 y.store (b)                ;
 while (a.load() == x)   a.store(1)
    ;

上例中,如果 T2 的 loop 结束,即它看到了 T1 对 y 的 store,则 T1 一定完成了 x = a.load(),而当 T2 完成 a 的 store 后,T1 的 loop 才会结束

 -Thread 1-       -Thread 2-                   -Thread 3-
 y.store (20);    if (x.load() == 10) {        if (y.load() == 10)
 x.store (10);      assert (y.load() == 20)      assert (x.load() == 10)
                    y.store (10)
                  }

上例的几个 assertion 均成立

Relaxed

memory_order_seq_cst 模型提供的是 happens-before 约束,relaxed 则去除了这一约束

例子

-Thread 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_relaxed)

-Thread 2-
if (x.load (memory_order_relaxed) == 10)
  {
    assert (y.load(memory_order_relaxed) == 20) /* assert A */
    y.store (10, memory_order_relaxed)
  }

-Thread 3-
if (y.load (memory_order_relaxed) == 10)
  assert (x.load(memory_order_relaxed) == 10) /* assert B */

这是刚才的例子,但在 relaxed 模型下两个 assertion 均可能 FAIL。例如当 T2 观测到 x == 10 时,T1 可能并未将 20 写到 y

但即使在 relaxed 的模型下,对同一变量的多次修改仍然是顺序的,例如

x = 0

-Thread 1-
x.store (1, memory_order_relaxed)
x.store (2, memory_order_relaxed)

-Thread 2-
y = x.load (memory_order_relaxed)
z = x.load (memory_order_relaxed)
assert (y <= z)

这个例子中的 assertion 永远不会 FAIL,如果在 T2 中 x load 到了 2,那么一定不会再 load 到 1

Acquire/Release

是前面两个模型的混合,Acquire/Release 只对互相依赖的变量保证 happens-before,没太看懂原文的例子,参考 Understanding memory_order_acquire and memory_order_release in C++11

memory_order_release 可以提交本线程在此之前的所有变更,而 acquire 到的线程一定能见到被 release 的变更。这里的变更包括非 atomic 变量,即当作 memory barrier 用

Consume

比 Acquire/Release 更松的模型,Acquire/Release 下所有 load 之后的读写都不许被移动到 load 之前,即在 load 时强制同步,但 Consume 模式下只有依赖原子变量的读写才会保证在 load 之后

n = 0
m = 0

-Thread 1-
 n = 1
 m = 1
 p.store (&n, memory_order_release)

 -Thread 2-
 t = p.load (memory_order_acquire);
 assert( *t == 1 && m == 1 );

 -Thread 3-
 t = p.load (memory_order_consume);
 assert( *t == 1 && m == 1 );

T2 中的 assertion 一定 PASS,因为 Acquire load 一定会同步 n = 1; m = 1

T3 中的 assertion 可能 FAIL,因为 Consume load 只会同步 n = 1,不保证同步 m

libwebsocket server 开发的坑

需要用 lws 写一个 server,踩了一些坑,记录如下。

  1. 自定义的 user* 在任何情况下都不会由 lws 构造销毁,如果在里面放容器等依赖构造析构的类,需要自己初始化
  2. 官方文档说不允许在 write callback 以外的场景写入数据。但似乎先检查 lws_send_pipe_choked() 再写入是没问题的,如果写不了了再缓存
  3. 回调里的 user* 可能是 nullptr,取决于回调对应的连接状态

gc的性能问题

在优化日志落盘的任务里,为了避免消费日志队列过慢导致爆队列,其中一个优化是快速消费队列并全部缓存到 deque 里,再慢慢落盘

但测试时发现,虽然理论上 dequeappend 是 O(1) 的,但实际表现是 append 时不时会变慢(体现为消费队列并 append 变慢导致队列爆掉),且随着 deque 变大,对应的延迟会变高

这个问题表现类似 is there a way to circumvent python list append becoming progressively slower,所以猜测可能也是 gc 导致的,禁用 gc 后确实不会变慢了,但随后发现引入了内存泄露,即虽然 deque 里缓存的元素被消费完了,但进程内存用量不会下降

由于 gc 只被用于回收有循环引用的对象,所以猜测是项目里的某些对象有循环引用,定位到了如下代码:

class Foo:
    @property
    def bar(self):
        try:
            return self._bar
        except AttributeError:
            self._bar = self.c_bar.contents
            return self._bar

其中 c_bar 是一个 ctypes.POINTER,在命中 AttributeError 时的逻辑会使 self._bar 引用 self.c_bar,产生循环引用,改掉这里后内存泄露消失

Linux 网络时间戳

为了衡量机内延迟,希望拿到收发包的时间戳

文档

网卡支持

$ ethtool -T eth0
Time stamping parameters for eth0:
Capabilities:
	software-transmit     (SOF_TIMESTAMPING_TX_SOFTWARE)
	software-receive      (SOF_TIMESTAMPING_RX_SOFTWARE)
	software-system-clock (SOF_TIMESTAMPING_SOFTWARE)
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none

即服务器只支持软件时间戳

时间戳获取

以 TCP 为例

RX

// 创建 socket 后
// 设置 SO_TIMESTAMPING 选项
{
    int timestamps = SOF_TIMESTAMPING_RX_SOFTWARE | SOF_TIMESTAMPING_SOFTWARE;
    if (setsockopt(sockfd, SOL_SOCKET, SO_TIMESTAMPING, &timestamps, sizeof(timestamps)) < 0)
    {
        perror("SO_TIMESTAMPING");
    }
}

// 用 recvmsg 接收数据
char *buf = new char[1024];
struct msghdr msg = {0};
struct iovec iov = {buf, 1024};
char control[CMSG_SPACE(sizeof(struct timespec))];

msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);

while (true)
{
    int size = recvmsg(sockfd, &msg, MSG_DONTWAIT);
    if (size == -1 && errno == EAGAIN)
        continue;

    for (auto cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg))
    {
        if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SO_TIMESTAMPING)
        {
            struct timespec* ts = (struct timespec*)CMSG_DATA(cmsg);
            printf("recv at %.9lf\n", ts->tv_sec + ts->tv_nsec * 1e-9);
        }
    }

    if (size > 0)
        break;
}

TX

// 创建 socket 后
// 设置 SO_TIMESTAMPING 选项
{
    int timestamps = SOF_TIMESTAMPING_TX_SOFTWARE | SOF_TIMESTAMPING_SOFTWARE;
    if (setsockopt(connfd, SOL_SOCKET, SO_TIMESTAMPING, &timestamps, sizeof(timestamps)) < 0)
    {
        perror("SO_TIMESTAMPING");
    }
}

// 发送数据后, 时间戳会被写入到 ERRQUEUE 中
char buf[1024];
// ...
while (true)
{
    write(connfd, buf, size);

    // read tx timestamp
    {
        /* For transmit timestamps the outgoing packet is looped back to the socket’s error queue with the send timestamp(s) attached.
         * A process receives the timestamps by calling recvmsg() with flag MSG_ERRQUEUE set and with a msg_control buffer sufficiently large to receive the relevant metadata structures.
         * The recvmsg call returns the original outgoing data packet with two ancillary messages attached. */
        struct msghdr msg = {};
        char control[CMSG_SPACE(sizeof(struct timespec))];
        msg.msg_control = control;
        msg.msg_controllen = sizeof(control);

        if (recvmsg(connfd, &msg, MSG_ERRQUEUE) < 0)
        {
            perror("recvmsg");
            exit(1);
        }
        for (auto cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL; cmsg = CMSG_NXTHDR(&msg, cmsg))
        {
            if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_TIMESTAMPING)
            {
                struct timespec *ts = (struct timespec *)CMSG_DATA(cmsg);
                printf("write %d bytes, tx timestamp: %.9lf\n", size, ts->tv_sec + ts->tv_nsec * 1e-9);
            }
        }
    }
}

测试

软件时间戳 overhead

在测试机上开启 RX 时间戳选项并通过 recvmsg 读取时间戳的 overhead 约为 100ns

软件时间戳生成时间点

文档描述

Request rx timestamps when data enters the kernel. These timestamps are generated just after a device driver hands a packet to the kernel receive stack.

即内核从驱动拿到数据包后就生成时间戳

对于字节流

The SO_TIMESTAMPING interface supports timestamping of bytes in a bytestream. Each request is interpreted as a request for when the entire contents of the buffer has passed a timestamping point. That is, for streams option SOF_TIMESTAMPING_TX_SOFTWARE will record when all bytes have reached the device driver, regardless of how many packets the data has been converted into.

即字节流的时间戳会 >= 最后一个字节对应的包的时间戳

实际测试如下场景:

  • 发送端几次发送间隔较久(1s),预期几个包也会间隔较久到达,如果内核立即处理并给每个包打时间戳,那么读到的 RX 时间戳间隔也应当是 1s。
  • 但接收方如果不及时读数据,而是 sleep 一段时间等发送方发完再读,会发现有连续好几秒的包(定义包为发送端的一次发送)有相同的时间戳,且值大于等于最后一个包的时间戳。

说明这些数据可能还是在驱动或者内核排队了,并且内核进入打时间戳的时机时,这些数据包已经被整合成了一个 skb 或是什么结构,因此一起得到了较晚的时间戳

暂时没有找到获取更准确时间戳的方法

io_uring 网络场景使用

背景

这两天在工作中发现 epoll + recvmsg 的组合在处理大量网络连接收数据的场景中耗时较长以至于成为了 bottleneck,思考可能是因为 syscall 次数太多,于是考虑 io_uring 能不能解决这个问题。

由于 io_uring 比较新,使用比 epoll 复杂但文档很少,记录一下个人学习后认为比较好的使用方式。仅考虑 C++/TCP 场景。这里不会注重解释框架的设计和细节,只是从使用者视角记录 roadmap。

可以先看总结

上手

几乎所有 API 都用 liburing 封装的即可

io_uring 本质上是一个 syscall queue:用户可以往请求队列(submission queue)里添加请求,然后期望内核在收到事件并处理后将执行结果写入到响应队列(completion queue)里。队列里的元素分别叫 submission queue entry(SQE)completion queue entry(CQE)。因为这两个队列是内核和用户空间共享内存的,所以读写队列不需要 syscall,这也是我期望其能有高性能的基础,不过在使用之后才发现这不是免费的 :(

使用上围绕一个 struct io_uring 展开,先设置必要的参数初始化这个结构

constexpr int ENTRIES = 1024;
struct io_uring ring;
struct io_uring_params params;
memset(&params, 0, sizeof(params));
// params.flags = ...
if (io_uring_queue_init_params(ENTRIES, &ring, &params) < 0)
{
    perror("io_uring_queue_init_params");
    exit(EXIT_FAILURE);
}

ENTRIES 就是 SQ 的大小,而 CQ 默认是两倍 ENTRIES 大,因为考虑一个请求可能会产生多个完成结果。

之后只需要往里面添加 syscall 请求即可,例如希望 TCP 建连:

/* 正常创建 fd 即可
* 值得注意的是,在使用 epoll 的时候,习惯把 fd 设成 non-blocking,在使用 io_uring 时就不需要了 */
int sockfd = ...;
struct sockaddr_in server_addr;
/* 填充 server_addr */

/* 生成一个 connect 请求 */

/* 由于可用的 sqe 是有限的,需要从 uring 里分配,如果用完了会返回 nullptr */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
assert(sqe != nullptr);

/* 基本上所有支持的 syscall 都会被命名成 io_uring_prep_xxx 的形式 */
io_uring_prep_connect(sqe, sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

/* 可以像 epoll 一样设置一个 u64 大小的 user_data, 例如指针 */
// io_uring_sqe_set_data(sqe, some_ptr);
struct user_data
{
    enum Op: uint32_t {
        CONNECT,
        RECV,
    };
    Op op;
    int sockfd;
};
static_assert(sizeof(user_data) == sizeof(__u64));
struct user_data data;
data.op = user_data::Op::CONNECT;
data.sockfd = sockfd;
memcpy(sqe->user_data, &data, sizeof(data));

/* 最后需要提交这个请求,内核才会看到这个请求并执行
* 注意这是个 syscall,因此如果有多个 sqe 需要提交,在所有 prep 之后调一次即可 */
io_uring_submit(&ring);

在默认模式下,内核收到这个请求后,会先立马检查请求需不需要等待,如果不需要就直接执行,否则会延后执行:在较老的 io_uring 实现里,会开一系列内核线程来不断地 poll 目标事件;在新的实现里,在事件就绪后内核会中断用户进程并切到内核态去执行。所谓的执行就是完成对应的 syscall 并把结果写入到 CQ 里这整个动作。

由于这些动作都在内核处理,在用户看来,提交任务之后过一段时间后去读 CQ 就能惊讶地发现请求已经完成了

while (true)
{
    struct io_uring_cqe *cqe;
    io_uring_peek_cqe(&ring, &cqe);

    if (cqe != nullptr) // 一段时间之后
    {
        /* 处理事件, 主要看 cqe->res 和 cqe->user_data
        * cqe->res 可以理解为对应 syscall 的返回值
        * cqe->user_data 用来对应自己的事件 */
        struct user_data data;
        memcpy(&data, cqe->user_data, sizeof(data));
        printf("op: %d, sockfd: %d, res: %d\n", data.op, data.sockfd, cqe->res);

        /* 处理完后,需要消费掉这个 CQE */
        io_uring_cqe_seen(&ring, cqe);
    }
}

这就是 io_uring 的基本使用方式了,连接建立之后,当然希望开始收数据,流程是类似的:

while (true)
{
    struct io_uring_cqe *cqe;
    io_uring_peek_cqe(&ring, &cqe);

    if (cqe != nullptr) // 一段时间之后
    {
        struct user_data data;
        memcpy(&data, cqe->user_data, sizeof(data));
        /* 处理完后,需要消费掉这个 CQE */
        io_uring_cqe_seen(&ring, cqe);

        /* 处理事件, 主要看 cqe->res 和 cqe->user_data
        * cqe->res 可以理解为对应 syscall 的返回值
        * cqe->user_data 用来对应自己的事件 */
        if (data.op == user_data::Op::CONNECT)
        {
            if (cqe->res != 0)
            {
                /* 错误处理 */
                // ...
                continue;
            }
            /* 建连成功, 准备收数据 */
            struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
            assert(sqe != nullptr);
            /* 注意, 这里的 buffer 需要是每个 sqe 独占的
            * 因此可以管理一个 per-connection 的 buffer */
            io_uring_prep_recv(sqe, data.sockfd, buffer, sizeof(buffer), 0);

            struct user_data data;
            data.op = user_data::Op::RECV;
            data.sockfd = data.sockfd;
            memcpy(sqe->user_data, &data, sizeof(data));
        }
        else if (data.op == user_data::Op::RECV)
        {
            if (cqe->res != 0)
            {
                /* 错误或者连接关闭 */
                // ...
                continue;
            }
            /* 处理收到的数据 */
            printf("recv %d bytes: %*s\n", cqe->res, cqe->res, buffer);
            /* 重新准备一个 recv 请求 */
            // struct io_uring_sqe *sqe = ...
        }
    }

    /* 提交刚才 prep 的请求 */
    io_uring_submit(&ring);
}

最佳实践

参考 io_uring and networking in 2023

IORING_SETUP_DEFER_TASKRUN

前面提到,io_uring 较新实现的默认模式里,事件 ready 后内核会中断用户进程,切换到内核态执行任务,所以看上去用户没有执行任何 syscall 就能 peek 到 CQE,但其实中间已经被打断过了。这样不仅没有减少切换到内核的开销,而且被打断的时间是不可控的。所以在默认模式下,io_uring 性能大概率不如 epoll。

为了避免这个问题,可以在初始化 io_uring 的时候设置 IORING_SETUP_DEFER_TASKRUN 标志(需要和 IORING_SETUP_SINGLE_ISSUER 一起设置)。这个标志的含义是,所有事件都不会再打断用户进程,而是会在用户显式指定内核处理之后才会被处理。

struct io_uring_params params;
memset(&params, 0, sizeof(params));
params.flags = IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_SINGLE_ISSUER;
io_uring_queue_init_params(ENTRIES, &ring, &params);

// ...

/* 告知内核执行 pending 的请求
* 最原始的方法是用 io_uring_enter 结合 IORING_ENTER_GETEVENTS
* 不过 liburing 里有很多替代, 例如 io_uring_wait_*, io_uring_submit_and_wait 等等
*
* 因为我的使用场景中, 最常见的 case 就是在事件循环里不断地 tick, 每次 tick 都会 submit 和 enter(GETEVENTS)
* 所以用 io_uring_submit_and_get_events 是最符合语义的 */
while (true)
{
    io_uring_submit_and_get_events(&ring);
    
    // peek CQEs
    // ...
}

我觉得可以把 io_uring_submit_and_get_events 理解成 epoll_wait 和遍历所有 epoll_event 并处理 IO 这两个动作打包到一起。

IORING_FEAT_FAST_POLL

io_uring 内部到底是使用多个内核线程还是中断用户进程,和这个标志相关。FAST_POLL 指代的是 io_uring 的 internal polling mechanism,可以理解为在内核做了类似 epoll 和 IO 的工作,来完成检测事件 ready 和处理 IO 并生产 CQE 的整套动作,这个实现应当比产生多个内核线程更高效。这个特性和内核版本有关,在 5.7 以后可用。

这个功能不需要手动开启,但是可以在 io_uring_queue_init_params 后检查 params.features & IORING_FEAT_FAST_POLL 来判断是否支持。

遍历 CQEs

比起每次 peek 一个 CQE 再 seen,可以使用 io_uring_for_each_cqe 来遍历所有 CQE,然后用 io_uring_cq_advance 来一次性消费。

while (true)
{
    io_uring_submit_and_get_events(&ring);

    unsigned head;
    unsigned count = 0;
    struct io_uring_cqe *cqe;
    io_uring_for_each_cqe(&ring, head, cqe) {
        ++count;
        /* 事件处理 */
    }
    io_uring_cq_advance(&ring, count);
}

Ring Provide Buffer

之前提到收数据时,需要每个 sqe 独占一个 buffer,因为不知道哪个请求会先完成,因此也没法保证不会竞争使用 buffer。provide buffer 可以解决这个问题。

其思想是

  1. 初始化一个 buffer pool,里面有一堆可用的 buffer entry
  2. 在 prep 需要 buffer 的请求时告知 io_uring,在请求需要 IO 时自己从 buffer pool 里取用
  3. 用户能在 CQE 里知道这次 IO 的数据在哪个 buffer 里
  4. 消费完成后将 buffer 归还到 buffer pool 里

代码例子

#define BUF_SHIFT       12
#define BUFFER_SIZE     (1 << BUF_SHIFT)
#define BUFFERS         4096
#define BGID            0

/* 保存 provide buffer 相关的上下文 */
struct provide_buffer {
    struct io_uring_buf_ring *buf_ring;
    unsigned char *buffer_base;
    int buf_ring_size;
} pb;

/* 获取某个 buffer, index 可以唯一标识一个 buffer */
static unsigned char *get_buffer(struct provide_buffer *pb, int idx)
{
    return pb->buffer_base + (idx << BUF_SHIFT);
}

/* 初始化 buffer pool, 其中
* BUFFERS 是 buffer pool 里 entry 的数量, 每个 entry 可以用 index 唯一标示
* BGID 是这个 buffer pool 的 唯一标示 */
static int setup_buffer_pool(struct io_uring *ring)
{
    int ret, i;
    void *mapped;
    struct io_uring_buf_reg reg = {
        .ring_addr = 0,
        .ring_entries = BUFFERS,
        .bgid = BGID,
    };

    pb.buf_ring_size = (sizeof(struct io_uring_buf) + BUFFER_SIZE) * BUFFERS;
    mapped = mmap(NULL, pb.buf_ring_size, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0);
    if (mapped == MAP_FAILED) {
        perror("buf_ring mmap");
        exit(EXIT_FAILURE);
    }
    pb.buf_ring = (struct io_uring_buf_ring *)mapped;
    io_uring_buf_ring_init(pb.buf_ring);

    reg = (struct io_uring_buf_reg) {
        .ring_addr = (unsigned long)pb.buf_ring,
        .ring_entries = BUFFERS,
        .bgid = BGID,
    };
    pb.buffer_base = (unsigned char *)pb.buf_ring + sizeof(struct io_uring_buf) * BUFFERS;
    ret = io_uring_register_buf_ring(ring, &reg, 0);
    if (ret) {
        perror("buf_ring init failed");
        exit(EXIT_FAILURE);
    }

    for (i = 0; i < BUFFERS; i++) {
        io_uring_buf_ring_add(pb.buf_ring, get_buffer(&pb, i), BUFFER_SIZE, i, io_uring_buf_ring_mask(BUFFERS), i);
    }
    io_uring_buf_ring_advance(pb.buf_ring, BUFFERS);

    return 0;
}

/* 归还一个 buffer */
static void recycle_buffer(int idx)
{
    io_uring_buf_ring_add(pb.buf_ring, get_buffer(&pb, idx), BUFFER_SIZE, idx, io_uring_buf_ring_mask(BUFFERS), 0);
    io_uring_buf_ring_advance(pb.buf_ring, 1);
}

/* 使用 provide buffer 的 recv 例子 */
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    assert(sqe != nullptr);

    io_uring_prep_recv(sqe, sockfd, nullptr, 0, 0);
    io_uring_sqe_set_flags(sqe, IOSQE_BUFFER_SELECT); // 告知使用 provide buffer
    sqe->buf_group = BGID; // 告知使用哪个 buffer pool
}

Multi-shot

像收数据或是 TCP server accept 这种场景,基本上写起来都是 prep 并等待完成,完成之后马上重新 prep 一次。对于这种操作,io_uring 提供了 multishot 模式:prep 一次之后,这个请求可以被重复触发,直到出错或被用户取消。

对于 recv_multishot,因为每次触发都需要一个额外的 buffer,所以必须配合 provide buffer 使用。

void add_socket_recv_multishot(struct io_uring *ring, int fd, unsigned flags)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    assert(sqe != nullptr);
    io_uring_prep_recv_multishot(sqe, fd, 0, 0, 0);
    io_uring_sqe_set_flags(sqe, flags | IOSQE_BUFFER_SELECT);
    sqe->buf_group = BGID;
}

Timeout

对于 connect,可能会希望给其设置一个 timeout,io_uring_prep_link_timeout 挺适合这种场景。

基本思路是,将一个 timeout 请求和另一个(组)请求绑定到一起,如果时间到了对应事件还没(全部)完成,就触发 timeout 并取消所有绑定的请求;否则取消 timeout。

例子可以在 liburing/test/link-timeout.c 测试代码里找到。

Cancel

这其实是一个疑点记录,io_uring 可以通过 fd 或者 user_data 作为 key 来 cancel 请求,其文档描述:

Although the cancelation request uses async request syntax, the kernel side of the cancelation is always run synchronously. It is guaranteed that a CQE is always generated by the time the cancel request has been submitted. If the cancelation is successful, the completion for the request targeted for cancelation will have been posted by the time submission returns. For -EALREADY it may take a bit of time to do so. For this case, the caller must wait for the canceled request to post its completion event.

即 cancel 被 submit 的时候就会被执行,保证 submit 完 CQ 里就会有 cancel 成功与否的结果。如果事件被成功 cancel,CQ 里也会有这些事件的 CQE。如果 cancel 返回 -EALREADY,则说明其他事件的 CQE 还没生成,但是已经在 cancel 中了。

疑点就在于,文档似乎没有说明如果一个事件已经在 CQ 里但还未被消费,此时 cancel 会不会影响这个 CQE,这决定了我能否认为 cancel 之后再消费 CQ 一定不会消费到 cancel 的事件,进而影响了用户空间 user_data 的生命周期管理。

总结

说了那么多,尝试用 io_uring 写了 TCP echo 吞吐测试,发现在连接数较低或者默认模式下,io_uring 性能不如 epoll,只有在连接数较多且配合 defer taskrun 的情况下,io_uring 才有优势。用 multishot 也有少量收益,但不如 defer taskrun 大,而且必须配合 provide buffer 也增加了使用难度。

在实际项目里,用 io_uring,结合 defer taskrun 并支持 fast poll,不启用 multishot 和 provide buffer 的情景下,性能比 epoll 差 :/

网上也有一些相关的讨论

总之,我认为 io_uring 在网络场景下不一定能得到预期收益,建议结合实际场景测试后再决定是否使用。

用 tcpdump 和 tshark 分析 wss 流量

需求

用 tcpdump 抓本地所有 wss 流量并得到对应的时间戳和明文

实现

1. SSL Keylog

为了解密 TLS 流量,需要得到每个会话的 keylog

如果 TLS 是自己写的,以 c++ openssl 为例,可以通过 SSL_CTX_set_keylog_callback 设置 keylog 回调,直接将输出的 keylog 写入文件即可。无须维护 keylog 和会话的对应关系。

假设最后得到的 keylog 文件是 keylog,be-like:

CLIENT_RANDOM 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
CLIENT_RANDOM 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
CLIENT_RANDOM 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef

2. 抓包

为了成功解密 TLS 流量,抓到的包必须包含 TLS handshake,所以抓包需要在目标连接建立以前开始

sudo tcpdump -w output.pcap -i any 'tcp port 443 or tcp port 9443'
# run wss client ...

3. 解密

抓包结束后,只要提供 keylog 文件和抓包文件,tshark 会自动解密并输出明文

tshark -r output.pcap -o "tls.keylog_file:./keylog"  -Y websocket -T fields -e frame.time -e websocket.payload.text

遇到的问题

truncated

一开始 tshark -e text 来获取明文,发现 websocket payload 超出 226B 的部分会被截断,并且输出前会展示 [truncated] 或者 [...],原因

解决方法参考 Displaying WebSocket traffic content

  • 使用 -e websocket.payload.text 来获取明文

如果一定要用 -e text,可以改源码里的 ITEM_LABEL_LENGTH,然后从源码编译 tshark

从源码编译 tshark,并修改 ITEM_LABEL_LENGTH

修改 ITEM_LABEL_LENGTH 为 2048:

diff --git a/epan/proto.h b/epan/proto.h
index 0d2a27ee86..42d627c168 100644
--- a/epan/proto.h
+++ b/epan/proto.h
@@ -48,7 +48,7 @@ extern "C" {
 WS_DLL_PUBLIC int hf_text_only;
  
 /** the maximum length of a protocol field string representation */
-#define ITEM_LABEL_LENGTH       240
+#define ITEM_LABEL_LENGTH       2048
  
 #define ITEM_LABEL_UNKNOWN_STR  "Unknown"

编译流程

git clone https://gitlab.com/wireshark/wireshark.git
cd wireshark
# setup, 需要根据系统选择脚本,参考 https://www.wireshark.org/docs/wsdg_html_chunked/ChapterSetup.html
sudo ./tools/rpm-setup.sh 
# 应用修改,假设修改是 patch.diff
git apply patch.diff
# 编译安装
mkdir build && cd build
cmake .. -DBUILD_wireshark=OFF
make && sudo make install

wolfSSL 使用

背景

c++ 写 TLS 客户端,openssl 实在是有些丑,另外也希望优化加解密性能,遂研究了一下 wolfSSL 的使用,因为看它:

安装及编译选项

源码拉取

官网有个 Download 页面,可以下载源码,其 github release 页也能拉源码,二者在 configure 时略有不同:前者可以直接 configure,后者需要先 ./autogen.sh

以后者为例

wget https://github.com/wolfSSL/wolfssl/archive/refs/tags/v5.7.4-stable.tar.gz -O wolfssl-5.7.4-stable.tar.gz
tar -xf wolfssl-5.7.4-stable.tar.gz
cd wolfssl-5.7.4-stable && ./autogen.sh

编译

大概是因为 wolfSSL 在设计之初是考虑给嵌入式设备用的,支持非常多的编译选项,所以非常可定制,当然也会导致刚开始用的时候也会比较 confusing,所有选项参考 Building wolfSSL

根据我的用例,我选择了如下选项,它们的作用分别是

  • --enable-tls13 启用 tls1.3
  • --disable-harden (性能)禁用安全强化
  • --enable-intelasm (性能)启用 intel 指令集加速,在我的 intel 机器上测试性能改进很显著
  • --enable-aesni --enable-sp --enable-sp-asm (性能)启用各种加速,不过这些在我的测试中并没有带来显著提升
  • --enable-singlethreaded (性能)如果能保证进程不会并发访问 wolfssl,可以启用
  • --enable-ed25519 因为用到了 ed25519 相关功能
  • --enable-opensslall 因为旧的代码是 openssl 写的,启用这个后会暴露非常多 openssl 的兼容接口,基本上可以无缝迁移
    • --enable-opensslextra 我没有启用这个选项,但是提一嘴,因为它实际上是暴露比 opensslall 更多的兼容接口(opensslall 虽然叫 all 但不是它的超集)
  • CFLAGS="-DLARGE_STATIC_BUFFERS" 启用这个选项后可以减少 malloc,参考 Library Design 的 Input and Output Buffers 章节
  • --libdir=/usr/local/lib64 指定安装路径

上面的选项里,性能相关的选项,除了 enable-intelasm 测试后有明显提升,其他选项均是看文档写了可能提升 performance 才启用的,但实际测试改进可能不显著,建议自己写个 benchmark 然后每启用一个选项测试一遍

另外我只关心 TLS Application 相关的性能,即握手完成后对称加解密的过程,所以只测了这个

完整的 configure 命令如下,包含了编译安装

./configure --enable-tls13 \
    --disable-harden \
    --enable-intelasm \      
    --enable-aesni \
    --enable-sp --enable-sp-asm \
    --enable-singlethreaded \
    --enable-opensslall \
    --enable-ed25519 \
    --libdir=/usr/local/lib64 \
    CFLAGS="-DLARGE_STATIC_BUFFERS"

make -j8 && make install

使用

参考 wolfssl-examples,用例非常全

在我的使用场景中,基本上只要把 openssl 头文件换成

#include <wolfssl/options.h> // 必须在所有 wolfssl include 之前
#include <wolfssl/ssl.h>

然后编译链接时把 -lcrypto -lssl 换成 -lwolfssl 即可,由于启用了 --enable-opensslall,所以基本上绝大多数 openssl 的 symbol 都可以直接使用,其会被替换成 wolfssl 的,例如

#define SSL_CTX WOLFSSL_CTX
#define SSL_new wolfSSL_new

I/O Callback

原本的 openssl 的代码里,I/O 用了 BIO,在启用 --enable-opensslall 后,wolfSSL 也提供 BIO 接口,也是可以无缝迁移的

但是看文档发现它还提供了 I/O Callback 接口,参考 Portability 的 Custom Input/Output Abstraction Layer 章节

此外,在看源码时发现,其所有 I/O 都是以 I/O Callback 实现的,例如设置 BIO 其实只是设置了 BIO callback,在需要读写时将数据写到 BIO 层。直接用 callback 相比 BIO 应该少了一次 memcpy,出于性能的考量打算使用这个接口

使用也比较简单,可以参考 wolfssl-examples/custom-io-callbacks,它实现了通过文件而非 socket 作为 I/O 进行 SSL 通信的例子

两个 callback 的定义如下

typedef int (*CallbackIORecv)(WOLFSSL *ssl, char *buf, int sz, void *ctx);
typedef int (*CallbackIOSend)(WOLFSSL *ssl, char *buf, int sz, void *ctx);

这两个 callback 的语义是

  • CallbackIORecv: 当 SSL 希望读取数据时,会调这个 cb
    • 其中 buf 是 SSL 内部的 buffer,sz 是 SSL 希望读取的字节数,我们需要将读到的指定字节数写入 buf
    • 返回实际读到的字节数或者 WOLFSSL_CBIO_ERR_WANT_READ 表示暂无数据
    • 例如,在 SSL_connect 之后,或者调用 SSL_read 时,即 ssl 希望读取控制消息或者 application data 时,会调用这个 cb
    • 在握手完成后的 SSL_read 过程中,wolfSSL 通常会先调一次 sz 为 5 的 cb 来试图读取消息头,然后再根据消息头的数据长度来读剩下的 payload
  • CallbackIOSend: 当 SSL 希望发送数据时,会调这个 cb
    • 其中 buf 是 SSL 希望发出的数据(加密后)
    • 返回实际发送的字节数或者 WOLFSSL_CBIO_ERR_WANT_WRITE 表示需要重试即可
    • 例如,在调用 SSL_connect 之后,或者调用 SSL_write 时,即 ssl 希望发送控制消息或者加密后的 application data 时,会调用这个 cb

ctx 则是用户自己设置的 userdata

在实现了自己的 callback 后,通过如下代码设置即可

wolfSSL_SSLSetIORecv(ssl, CBIORecv);
wolfSSL_SSLSetIOSend(ssl, CBIOSend);
// wolfSSL_SetIOReadCtx(ssl, userdata);
// wolfSSL_SetIOWriteCtx(ssl, userdata);

减少 malloc

wolfSSL 支持自定义 allocator(malloc, free, realloc),参考 Portability 的 Memory Use 章节

int wolfSSL_SetAllocators(wolfSSL_Malloc_cb  malloc_function,
                         wolfSSL_Free_cb    free_function,
                         wolfSSL_Realloc_cb realloc_function);

也因此,我尝试用一个包含调用计数的 malloc 来观察 malloc 的次数,然后发现在不启用 LARGE_STATIC_BUFFERS 的情况下,除了握手阶段以外,每次读或者写 SSL 都会出现一次 malloc(和相应的 free)

如果启用 LARGE_STATIC_BUFFERS,每个 SSL 都会有一个固定大小的 staticBuffer,其大小应该是 MAX_RECORD_SIZE,即 16KB,在读写数据时只要这个 buffer 够用,就不会出现 malloc

另一个方法则复杂一些,参考 Features 的 Static Buffer Allocation Option 章节,大概流程是:

  • configure 时启用 --enable-staticmemory
  • 预先分配两个内存区域,并传递给 wolfSSL,需要调用两遍 wolfSSL_CTX_load_static_memory
    • 下面这个 example 是 Features 里抄的,但 WOLFMEM_IO_FIXED 应该改成 WOLFMEM_IO_POOL_FIXED
      WOLFSSL_CTX* ctx = NULL; /* pass NULL to generate WOLFSSL_CTX */
      int ret;
      
      #define MAX_CONCURRENT_TLS  0
      #define MAX_CONCURRENT_IO   0
      
      unsigned char GEN_MEM[GEN_MEM_SIZE];
      unsigned char IO_MEM[IO_MEM_SIZE]; 
      
      /* set up a general-purpose buffer and generate WOLFSSL_CTX from it on the first call. */
      ret = wolfSSL_CTX_load_static_memory(
              &ctx,                               /* set NULL to ctx */
              wolfSSLv23_client_method_ex(),  /* use function with "_ex" */
              GEN_MEM, GEN_MEM_SIZE,            /* buffer and its size */
              WOLFMEM_GENERAL,                  /* general purpose */
              MAX_CONCURRENT_TLS);              /* max concurrent objects */
      
      /* set up a I/O-purpose buffer on the second call. */
      ret = wolfSSL_CTX_load_static_memory(
              &ctx,                /* make sure ctx is holding the object */
              NULL,                           /* pass it to NULL this time */
              IO_MEM, IO_MEM_SIZE,                /* buffer and its size */
              WOLFMEM_IO_FIXED,                             /* I/O purpose */
              MAX_CONCURRENT_IO);               /* max concurrent objects */
      
      if (ret != 0)
      {
          auto error = wolfSSL_ERR_get_error();
          if (error != WOLFSSL_ERROR_NONE)
          {
              char error_str[256];
              wolfSSL_ERR_error_string(error, error_str);
              throw std::runtime_error("wolfSSL_CTX_load_static_memory error: " + std::string(error_str));
          }
      }
      
  • 之后的使用应该不需要修改,在 statcmemory 够大的情况下,wolfSSL 会自动在上面拿内存,本质上就是实现了一个预分配的 malloc

不过其实 staticmemory 本质上还是要每次都分配内存(只是在预分配的区域上分配),相比 LARGE_STATIC_BUFFERS 在理想情况下可以完全不分配内存,后者应该更符合我的需求

在我的测试里,在收发数据包较小(因此单次 malloc 成本很低)的情况下,性能表现 LARGE_STATIC_BUFFERS > malloc > staticmemory

Benchmark

简单测试了 TLS Client 端,在 TLS1.3 用 TLS_AES_128_GCM_SHA256 cipher 的情况下,连续收发 256B payload,加解密的耗时(ns)

openssl 作为 baseline, 在使用 BIO 的情况下

encryption_costs: min: 357, max: 11197, avg: 388, p50: 370, p99: 546
decryption_costs: min: 316, max: 16816, avg: 350, p50: 330, p99: 532

wolfSSL 不启用 intelasm 且使用 BIO

encryption_costs: min: 1074, max: 16198, avg: 1132, p50: 1098, p99: 1335
decryption_costs: min: 1143, max: 16839, avg: 1205, p50: 1169, p99: 1427

启用 --disable-harden,没有观察到提升

encryption_costs: min: 1067, max: 11969, avg: 1127, p50: 1092, p99: 1338
decryption_costs: min: 1139, max: 21007, avg: 1206, p50: 1168, p99: 1433

启用 --enable-intelasm,性能提升显著

encryption_costs: min: 200, max: 10915, avg: 228, p50: 214, p99: 304
decryption_costs: min: 263, max: 8928, avg: 309, p50: 296, p99: 407

启用 --enable-sp-asm,没有观察到提升

encryption_costs: min: 205, max: 16980, avg: 244, p50: 235, p99: 319
decryption_costs: min: 258, max: 15187, avg: 300, p50: 286, p99: 388

启用 --enable-singlethreaded,提升不大,但似乎是有些提升

encryption_costs: min: 211, max: 10392, avg: 249, p50: 240, p99: 331
decryption_costs: min: 238, max: 12050, avg: 281, p50: 261, p99: 373

在发送方向上用 Callback,接收方向上仍然用 BIO,且启用 LARGE_STATIC_BUFFERS,提升较大

encryption_costs: min: 124, max: 10720, avg: 131, p50: 131, p99: 138
decryption_costs: min: 172, max: 11071, avg: 184, p50: 183, p99: 208

再启用 fast-mathfast-huge-math,没有观察到提升

encryption_costs: min: 124, max: 17766, avg: 132, p50: 131, p99: 147
decryption_costs: min: 172, max: 16989, avg: 184, p50: 183, p99: 208

总结就是使用 IOCallback,启用 LARGE_STATIC_BUFFERS,启用 intelasmsinglethreaded(如果确认不会多线程使用),能达到比较好的加解密性能,比 openssl 快

部署 mdbook 到 github pages

本网站由 mdbook 生成,通过 github actions 持续集成到 github pages,以下为部署方式

1. 为项目创建仓库

BlackCloud37/mdbook-blog,其 master 分支为 mdbook 项目

2. 添加 Github Action

参考 GitHub Pages Deploy,创建 .github/workflows/deploy.yml,内容为

name: Deploy
on:
  push:
    branches:
      - master

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
      with:
        fetch-depth: 0
    - name: Install mdbook
      run: |
        mkdir mdbook
        curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.14/mdbook-v0.4.14-x86_64-unknown-linux-gnu.tar.gz | tar -xz --directory=./mdbook
        echo `pwd`/mdbook >> $GITHUB_PATH
    - name: Deploy GitHub Pages
      run: |
        # This assumes your book is in the root of your repository.
        # Just add a `cd` here if you need to change to another directory.
        mdbook build
        git worktree add gh-pages
        git config user.name "Deploy from CI"
        git config user.email ""
        cd gh-pages
        # Delete the ref to avoid keeping history.
        git update-ref -d refs/heads/gh-pages
        rm -rf *
        mv ../book/* .
        git add .
        git commit -m "Deploy $GITHUB_SHA to gh-pages"
        git push --force --set-upstream origin gh-pages

之后所有到 master 的 push 都会 build 当前的 mdbook,并将产物 push 到本仓库的 gh-pages 分支

3. 访问网站

gh-pages 分支下的网页会被部署到 <Username>.github.io/<Reponame> 域名下,如本项目即为 blackcloud37.github.io/mdbook-blog,直接访问即可

如果希望部署到 <Username>.github.io, 则仓库的名字需要为 <Username>.github.io,如 [BlackCloud37.github.io]

4. 自定义域名

参考 GitHub Pages Custom Domain

  1. 在 master 的根目录下添加 CNAME 文件,内容为顶级域名,如 blackcloud37.com
  2. 部署脚本里 copy CNAME 到 gh-pages
    mv ../book/* .
    cp ../CNAME . # COPY CNAME
    git add .
    
  3. 添加两条 DNS 记录, 指向 github pages 的服务器
  • A, @ , 185.199.108.153
  • A, www, 185.199.108.153

5. Trouble shooting

第一次推送触发 github action 后,gh-pages 分支存在并且已经包含产物,但是访问 blackcloud37.github.io/mdbook-blog 提示 404,参考 https://stackoverflow.com/questions/11577147/how-to-fix-http-404-on-github-pages,推送一个空 commit 到 gh-pages 分支即可:

# 在 gh-pages 分支
git commit --allow-empty -m "Trigger rebuild"
git push

另外,仓库可见性必须为 Public

实现校外 IPv4 访问北邮人

为之后毕业离校做打算,用 SSR + 海外 IPv6 服务器做代理访问 byr

搭建服务器

  1. Vultr 买个最便宜的机器,为了稳定系统选了 CentOS 7,记得 enable ipv6

  2. 参考 SSR一键安装 安装 ssr 服务端

    wget --no-check-certificate -O shadowsocks-all.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-all.sh
    chmod +x shadowsocks-all.sh
    ./shadowsocks-all.sh 2>&1 | tee shadowsocks-all.log
    

    配置选择

    Your Protocol         :  auth_aes128_sha1
    Your obfs             :  tls1.2_ticket_auth
    Your Encryption Method:  aes-256-cfb
    

    相关命令

    启动SSR:
    /etc/init.d/shadowsocks-r start
    退出SSR:
    /etc/init.d/shadowsocks-r stop
    重启SSR:
    /etc/init.d/shadowsocks-r restart
    SSR状态:
    /etc/init.d/shadowsocks-r status
    卸载SSR:
    ./shadowsocks-all.sh uninstall
    

    配置文件: /etc/shadowsocks-r/config.json,可以检查一下里面有没有 server_ipv6,以及需要将 dns_ipv6 改为 true

客户端

  1. 安装 ssr 客户端
    • 对于 osx,可以用 brew install shadowsocksx-ng-r --cask,配置连接
  2. 在 ssr 客户端里添加 byr 的规则以对相关域名使用代理

下载

参考 如何在普通网络环境下上北邮人

以 qBittorrent 为例,需要配置

  1. Connection - Proxy 填入 ssr 的 local 代理地址/端口
  2. 关闭 Advanced 里的验证 tracker 证书

Reference

数据安全

给科技服务队写的,介绍如何备份数据

0. 关于最常见的不良习惯

本文的正文从第 1. 节开始,但由于大部分数据丢失与一些不良使用习惯相关,我们在这个单独的置顶的篇幅中罗列这些习惯的 Checklist,以便引起足够的重视。这些行为将极大增加你丢失重要数据的概率,请详细阅读:

  1. 移动、震动、磕碰运行中的移动机械硬盘。这是我们发现的最常见的移动机械硬盘的损坏原因之一。运行中的机械硬盘必须被稳定放置,避免任何形式的机械震动,原因参考 3.4 节

    如果机械硬盘在使用中遭到磕碰,并发出咔哒或者沙沙的摩擦声,且硬盘不可读,请立刻断电并寻求帮助。

  2. 只在一个设备上保存你的重要文件,例如将所有重要资料单独保存(与备份相对应)在电脑的一块硬盘(Windows 的多个盘符可能属于同一块硬盘)、移动硬盘、U盘、SD卡中

  3. 在任何可移动存储设备上长期保存重要资料,尤其是 U盘、移动机械硬盘。基本上你可以视它们为随时都可能损坏的设备,细节参考 3.4 节

  4. 强行断开任何使用中的存储设备的电源,例如热插拔移动硬盘、未关机状态下断开台式机电源等,这有概率导致文件系统损坏甚至是存储设备物理损坏(尤其是机械硬盘,参考 3.4 节)

  5. 随意下载安装软件,随意在打印店等公开场所使用U盘并将其插回自己的电脑上

1. 序言

数据安全与 “数据丢失” 对应,指 “保护你的关键数据不丢失” 及 “维护你工作系统数据完整性” 的过程。在我们的经验中,(数据安全)通常对你的学习、工作生活至关重要,但通常也被大部分没有相关经验的人忽视,经常导致一些严重事故(例子参考第 2. 节)。

本文将介绍科技服务队遇到过的常见数据丢失例子,旨在引起不关注数据安全的人的重视(第 2. 节),并介绍数据丢失的成因及为何其通常比许多人想象的严重,并简单介绍急救原则(第 3. 节),最后介绍维护数据安全的基本原则和具体工具(第 4., 5. 节)。

如果你发现可能遇到了数据丢失相关的事件,可以参阅第 3. 节来获取可供参考的急救手段。

如果你只是希望学习日常维护数据安全的方法和习惯,请直接阅读第 4., 5. 节。

2. 常见数据丢失例子

在阅读之前,我们通常可以从一个角度出发:目前对你而言最重要的数据是什么,如果它此刻完全消失不可恢复,你的生活会因此受到多大的冲击。

例如,你丢失了:

  • DDL 是今晚的作业,写了一天但还没提交
  • 陆续经营几年的游戏存档
  • 过去几年的照片、聊天记录等
  • 大学期间的各类申请材料,也许与你正在进行的申请相关
  • 工作数月的大作业、课程设计或毕业设计
  • 几年的数 TB 的实验数据,或者撰写中的论文稿

通常如果上述事件真实发生,你的学习、工作将会受到很大影响。而且在我们的经验中,上述每一项都有人经历过(甚至多次),并且都是以一种不可恢复的形式。它们可能会由以下具体事件导致(同样是我们经历过的):

  1. 误操作导致撤销、删除重要文件或格式化含有重要数据的设备
  2. 由于数据被加密(Bitlocker 等)并丢失了密钥导致设备数据无法读取
  3. 由于下载不明渠道的软件或在不可信的地方使用USB设备导致中毒
  4. 存储设备物理损坏
  5. 设备丢失、自然灾害等不可抗原因

在下一节中将简单介绍上述事件的成因及后果。

3. 数据丢失的成因及后果

3.1 误操作

成因

通常是疏忽,或是由于对使用的系统及自己的操作不理解。例如不注意自己删除了哪些文件(删除时选中了过多的文件);不理解 Ctrl-Z(Win)/Cmd-Z(Mac) 对应的撤销操作会导致自己创建文件的行为被撤销导致文件消失;不理解格式化、重置系统(清除用户数据)的含义或是不经确认就进行这类操作;不熟悉网盘或是 Git 等涉及文件管理的软件的操作导致误操作等。

后果

通常与删除操作发生到现在经过的时间、及期间硬盘上写入事件发生的次数相关(例如写入文件等)。同时,如果数据在机械硬盘(HDD)上,恢复概率较高,在固态硬盘(SSD)上时难以恢复。

⚠️注意:在现在(2023年)的常见 PC 中,由于绝大部分 PC 都使用固态硬盘(SSD)安装系统,且通常会开启 TRIM,故上述操作不可恢复的概率非常高。这是由于 HDD 与 SSD 的存储机制与空间管理机制不同导致的,例如前者不会经常擦除已经删除的文件的数据,只会在有必要时覆盖,后者会通过回收已删除文件的数据块的机制来保证写入速度,但这导致被删除的文件的数据会很快消失。

建议

发现之后立刻停止创建文件等可能会对数据所在盘做写入操作的行为,并询问专业人士下一步操作,在不清楚你在做什么的情况下不要自行通过安装软件等方式试图恢复数据。

3.2 数据加密

成因

通常是 Windows 系统下的 Bitlocker 导致的,简单来说它会通过软硬件的加密手段将你的整块硬盘上的数据加密,在你的系统遭到破坏时(许多原因会导致这个机制被触发),必须使用一个密钥才能恢复数据。这个密钥可以通过 在 Windows 中查找 BitLocker 恢复密钥 找到,也可以在你系统可以正常使用时导出。

后果

如果你没有密钥并且 Bitlocker 被激活,几乎没有任何手段可以恢复你的数据。

建议

务必妥善备份并保管你的密钥。或者可以提前关闭 Bitlocker,但这会有数据泄露风险,因此这不构成我们的建议。

唯一的恢复手段是找到密钥的备份,或者通过微软账户找到密钥。

3.3 病毒及恶意软件

成因

缺乏安全意识的用户可能无法分辨网上软件的安全性,当用户在使用国内搜索引擎时尤甚,会误下载到病毒软件。一些流氓软件(也可能以杀毒软件或电脑管家的形式出现)也在此范畴。Windows 为此类软件的重灾区。

校内一些公共场合的设备由于使用者众多可能是包含病毒的,在这些设备上使用了你的 USB 设备可能会导致 USB 设备中毒,可能会导致数据被破坏,或者将病毒带到你自己的机器上导致更严重的后果。

同时,如果用户安装了一些会对外暴露端口的软件/服务,未设置防火墙并在无 NAT 的网络环境下(校园网就是一种),会导致这些端口被攻击,许多服务都不会设置用户鉴权,并可能有很多已知漏洞,因此任何一个攻击者都可能通过非常简单且自动化的方式攻击这些服务,并对运行服务的机器做许多事。其中包括加密你的数据并借此勒索。

后果

通常视病毒及勒索软件的种类而定,例如一些此类病毒/软件可能已经有已知的恢复被破坏数据的方法。但是如果没有对应的方法,数据将难以恢复。

建议

使用正版软件,从可信的渠道下载软件,包括官网、清华的 its.tsinghua.edu.cn、操作系统官方的应用商店 (Windows 的应用商店、Mac 的 AppStore) 等,不包括第三方的软件管家、搜索引擎上找到的软件下载站等。如果你不能确认你是否正从正确的地方下载软件,请咨询了解的同学。

避免安装各类第三方杀毒软件、电脑管家。但开启系统自带的防护机制,例如 Windows Defender。

不要在 USB、移动硬盘等会在不受信任的其他电脑上(例如打印店)使用的设备上存放重要数据,如果有,请提前在其他地方备份。

如果遇到了数据被破坏、加密勒索的情况,请咨询专业人士如何恢复(通常需要对症下药)。

3.4 存储设备损坏

成因

常见的存储设备有机械硬盘(HDD)、固态硬盘(SSD)和闪存(U盘)。

其中机械硬盘由于依赖磁头在高速旋转的盘片上读写数据,因此非常容易因为震动、磕碰、意外掉电等情况导致磁头摩擦盘片,进而导致损坏,导致数据丢失。供电不足(常见于 USB 移动硬盘 + 拓展坞的组合,或者是劣质硬盘盒)或者机械结构本身的磨损也会导致类似事件发生。

固态硬盘的颗粒擦写寿命有限,且目前消费级产品的颗粒寿命较短,在累积写入过多数据之后一些块会不可逆地损坏,最终导致数据丢失。这个寿命指标通常会在固态出售时以 TBW 的形式告知用户,如 300TBW 表明其设计寿命是总共写入(Write) 300TB。容量越大的固态硬盘该指标通常越大,即寿命越长。

闪存的存储原理与固态硬盘类似,但注意大部分 U盘 的产品定位不是长期稳定存储数据,而是短期移动数据,因此其寿命非常不可靠。不要将任何重要数据单独存放在 U盘 中。

后果

机械硬盘损坏后,只要盘片保存良好并且上面没有过多磨损,可以通过专业数据恢复机构开盘读取磁信息来恢复数据,但价格通常较高。

固态硬盘与 U盘 损坏后,数据通常无法恢复,且其寿命不可靠,因此不要将任何重要数据单独存放在它们上。

建议

经常查看硬盘的 S.M.A.R.T. 信息,可以将它理解为硬盘的健康度。正常使用的硬盘通常不会突然损坏,损坏是一个渐进的过程。例如硬盘设计通常会给坏块预留缓冲,即一些备用块可以被用于替换损坏的块,当这些备用块被使用时,SMART 会给出相应的警告数值,当备用块用完时才会出现更严重的事。在此之前查看 SMART 即可了解相关问题,并及时处理。也可以通过类似 Disk Genius 的软件扫描 HDD 的坏道,确认没有坏道时再在其上存储数据。

对于任何存储设备,避免突然断电(例如拔掉台式机电源,或是热插拔运行中的移动存储设备)。不要使用劣质的硬盘盒或转接头/扩展坞(尤其针对移动 HDD)。

对于 HDD,避免在其运行时震动甚至磕碰它。

对于 SSD 和 U盘,长期不通电时,其上的数据可能会逐渐由于电荷流失而丢失,因此不要试图用这些设备长期存储冷数据。如果有类似的需求,请使用 HDD、光盘甚至磁带。

3.5 设备丢失等

对于可移动设备这比较常见(例如手机或者 U盘),手机中可能会有照片、聊天记录等重要数据,因此建议参考后面的章节经常备份数据。

4. 维护数据安全的基本原则

除了第 3. 节中提到的各种建议外,本章将介绍通用的维护数据安全的原则:备份

⚠️ 没有任何手段能代替备份。第 3. 节中提到的所有建议都不能替代备份,且考虑到其中提到的数据丢失原因均比较常见(没有侥幸),如果你发现你有对你而言重要的数据,且它们没有按照本章中描述的原则被正确备份,强烈建议你参考本章进行备份。

备份原则:3-2-1

这是一个备份的黄金准则,少于这个标准的备份在意外情况下可能无法恢复数据,因此失去备份的意义

  • 3: 重要文件需要被完整存储 3 份,一份原件,两份拷贝。
  • 2: 三份文件需要被保存在至少 2 种不同的介质上,如 HDD、SSD、磁带、光盘等(不建议使用 U盘),同时电脑自带的硬盘和外置硬盘也可以视为不同介质,HDD/SSD 在家用场景下比较容易获取。
  • 1: 三份文件中至少有 1 份保存在异地。

3 份备份保证数据同时被毁的概率足够低,因为这个概率随着备份变多指数降低。

2 份不同介质是由于相同介质通常会由于相同的原因损坏,因此它们同时损坏的概率较高,例如一次电脑意外断电 可能 会同时导致你电脑里的所有 HDD 都挂掉,但 SSD 可能相对更不容易在这种情况下损坏;或者电脑中毒可能会导致电脑上的所有数据损坏,但外置硬盘不会受影响。不同介质保存数据的物理形式也不同,因此在相同的环境下同时损坏的概率较低。

1 份异地通常是考虑到自然灾害或者盗窃等的影响。异地不一定要很远,例如宿舍和实验室/公司/家里也可以算异地。这也可以通过各类可靠的云盘实现(*度云等可能会篡改你数据的云不在此列),且云盘比自行维护异地的物理存储更方便,因此更为推荐。

最简单且常见的例子是,在你的工作机上保存一份数据原件(通常在 SSD 中),同时定期(最好通过自动化的方式)将其拷贝到一份外置的机械硬盘中,这样就做到了 2 份文件 + 2 种介质,最后再在一个云盘中存储一份备份,即完成了 3-2-1 原则。

5. 常见备份方法

注1:本节中的 备份 和 同步 可能会被统称为备份。但狭义上,备份指将数据的某个状态保存到备份设备中,并可能包含压缩和去重等操作,同步则是连续地时刻维持两个设备上的数据相同。由于同步无法保证误操作删除数据时远端数据不被删除,因此其不能替代备份。

注2:可能有用户会使用 RAID,但这里认为任何形式的 RAID 都不是备份,也不能替代备份的功能。建议将 RAID 后的组作为单块介质看待。

本节主要介绍实现 3-2-1 备份的可用手段及工具。参考第 4. 节中的例子,我们假设数据原件在一台装有 SSD 的电脑中,并希望备份到一个本地外置硬盘 + 一个云

数据备份的间隔取决于你要备份数据的重要程度,例如你可以忍受几天的数据丢失。另外,任何里程碑式的数据更新(例如拍了一批新照片、有阶段性的工作成果)之后也建议完整备份数据。

5.1 本地备份

当你只需要备份少量重要数据文件(如文档等)时,你可以直接像平时使用移动硬盘一样备份这些数据:格式化外置硬盘(注意选择日志式文件系统,如 Windows 常见的 NTFS,或是 Ext4 等),在里面创建文件夹并存放你的文件。然后记得妥善保存这块外置硬盘。

在 Windows/Mac 下,你也可以借助一些数据同步软件来更便捷地完成这个过程,例如你总是需要将系统中的某个文件夹同步到外置硬盘中的另一个文件夹,这个机械化的操作可以通过一些第三方的数据备份工具/软件进行,在设置之后可以一键执行备份操作,也可以定时触发备份等。同时当你备份的文件夹包含较多备份过的重复文件时,这类软件提供的 diff 算法可能能更高效地完成备份(而不是像系统自带的资源管理器,需要覆盖每个重名文件)。

如果你在使用 MacOS,可以通过其自带的 Time Machine 完整地备份系统的多个快照,恢复到任意一个其中包含的时间点(包括系统状态和系统里的数据)。因此强烈建议 Mac 用户使用至少一块和系统硬盘一样大的外置硬盘来定期做 Time Machine。

Linux 用户可以使用 Rsync 进行备份,通过 cronjob/system service 进行定时操作,对于特定的文件系统(例如 Btrfs/Zfs),也可以使用快照的方式实现类似 Time Machine 的备份效果,但注意在同一块盘上创建的快照不能视为备份。请至少将其保存到另一个介质中。

当你需要备份一些不经常访问的冷数据时(例如归档学习资料),将它们压缩成压缩包后再进行备份操作效率会更高。一些专业的备份软件也会提供压缩和去重的操作,常见于一些商用 NAS 系统的备份工具,但这可能需要更复杂的条件和设备。

5.2 多端备份

你可以选择将数据备份到一块单独的介质中并保存到异地来实现这一点。不过使用云盘通常是一种更便捷的方式。本小节将介绍常见的云盘。

  • 国内云盘存放的数据可能会未经你的同意被泄露、删除、替换。在使用之前请认真考虑这一点。如果一定要使用,建议将数据加密并压缩再上传,且不要将鸡蛋放在一个篮子里。

  • 本节提到的云盘与科技服务队利益无关。

清华云盘

⚠️ 清华云盘会在毕业时被回收。因此一定注意在毕业前将其上的数据备份到其他地方。包括个人资料库及个人创建的群组资料库。

清华大学为学生提供了一个可用的云盘,可以通过校内账号访问,其 使用指南 介绍了细节。它基于 SeaFile 搭建,Web 界面 可以像普通网盘一样使用。同时 Seafile 也有客户端,可以在 Windows/Mac/Ubuntu/Debian/Fedora 及 Android/iOS 中使用。客户端支持同步盘和挂载盘两种模式。具体使用细节均在上述使用指南中被详细描述。

其中,同步盘 “可以实现将云盘资料库和本地文件夹(目录)关联和自动同步的功能。用户关联云盘资料库和本地文件夹后,客户端将自动同步本地和云端内容,本地和云端的文件和目录的新建、修改、删除、重命名等变化都会保持一致“,即将本地某个目录完全同步到云端,二者的文件会维持一致的状态。此时文件在本地云端各有一份。

同步盘在正确同步后,假设本地硬盘突然损坏,在云端应当能找到最后一次同步时的数据,起到备份的效果。但是由于同步会实时进行,可能会由于网络原因出现意料外的 Bug。同时如果在本地误操作删除/修改了同步盘中的文件,该操作可能会被即时同步到远端,因此这种同步的方式严格来讲不能算备份(即使删除操作可能可以通过 Seafile 的回收站找回)。

挂载盘则相当于将云盘资料库单独作为本地的一块磁盘使用,此时电脑上会多出一块虚拟的 “硬盘”,其被映射到云盘的资料库。与同步盘的区别在于,此时文件只在云端有一份,本地硬盘上并没有相关数据。因此可以将其作为网盘 Web 界面的本地版使用,可以较方便地实现将本地文件上传到云盘的操作。

上述方式可以根据需求选择,例如在 3-2-1 原则下:

  • 如果使用同步盘,则只需要维护本地文件夹和一块外置硬盘,本地文件夹会被同步到云端资料库,达到 3-2-1 的要求(由于这种同步方式严格来讲不能算备份,建议使用挂载盘模式)
  • 如果使用挂载盘,则本地源文件夹为一份数据,本地挂载盘即云端资料库,为一份远端数据,备份时需要同时将本地源文件夹中的数据备份到本地挂载盘和外置硬盘(二者操作几乎相同,因为这种情况下挂载盘和外置硬盘都可以视为单独的备份盘),达到 3-2-1 的要求。通过 Web 界面管理文件本质与挂载盘相同。

坚果云

国内网盘,提供不限量的免费空间,但是限制了每月上传 1GB、下载 3GB。优势是相对没有那么流氓,例如免费版也不会限速。其也提供了多平台的客户端,提供了普通网盘的使用体验。

同时其支持 WebDAV 协议(坚果云第三方应用授权WebDAV开启方法),因此可以在不适合安装客户端但支持 WebDAV 的系统下挂载(Windows/Mac/Linux 均可)。

阿里云盘/腾讯微云/百度云盘等

均为国内使用量比较多的云盘,此外还有联通/移动/电信等运营商的云盘和蓝奏云盘等。可以根据其定价策略和使用体验来选择。

OneDrive/iCloud

二者分别为 Windows 和 Mac/iOS 自带的云盘,分别需要微软账户和 Apple 账户使用。作为系统内置的云盘,其主要目标是实现多端同步(例如 Mac/iOS 设备间的照片、文档同步,多台 Windows 设备间的资料同步),它们均提供了一定量的免费空间(但比较小),且基本模式与清华云盘的同步盘模式相同,能够将系统的指定文件夹(桌面、文档、照片等)同步到远端,并让多台设备共享这些文件夹。由于同步严格来说不能算备份,因此不建议将它们视为备份手段。不再展开介绍。

同时,iCloud 还可能会由于 Apple 账号登出等原因导致本地数据被抹掉,又由于网络原因难以从云盘中重新下载,因此对于重要数据请遵循 3-2-1 原则并寻找其他备份手段。

Google Drive/Dropbox/Mega

均为国外网盘,适合有条件访问的人使用。

对象存储服务

这类服务通常是面向开发者的,使用起来可能不会有面向个人的云盘方便,因此只是提供一个思路。

且这类服务通常没有免费版,虽然某些情况下价格可以做到很低,例如 AliyunOSS 冷归档 50G 的数据只需要 9元/年。

一些云服务提供商会提供对象存储服务(例如阿里云 OSS、腾讯云 COS),其冷归档存储的计费较为便宜,以 Aliyun OSS 为例:冷归档数据需要至少存储 180 天,并且在取回时需要解冻(即等一段时间才能下载),上传不计费,取回时按数据解冻量收费,存储计费为 0.015元/GB/月,适合用于长期存储冷数据,即需要长期存储但几乎不会访问的(重要不常用的)数据,例如几年前的课程归档。

以 Aliyun OSS 为例,注册 Aliyun 账户后,可以参考 开始使用OSS 中的 使用 OSS 控制台

  1. 开通 OSS 服务
  2. 创建 Bucket(可以理解为一个资料库,所有设置都是以 Bucket 粒度区分的)
  3. 设置 Bucket (主要为存储类型,例如冷归档存储,并关闭传输加速等计费项)
  4. 在 Bucket 内创建文件夹并上传、下载

在 Bucket 创建后,也可以通过 开始使用OSS 中的 使用图形化管理工具ossbrowser 一节来获取一个客户端,通过客户端管理 Bucket 内的文件,达到类似使用网盘的效果,在备份的场景中是够用的。

在存储冷备份数据时,可以先在本地压缩并加密后再上传到云端,减少计费并保证数据安全,如果加密记得备份加密手段及密钥。

6. 不同成本的备份例子

本节列举多种不同成本(由低到高)的备份例子,供读者参考。选择方案时请综合考虑数据重要程度、数据量与数据访问/修改频繁程度。如果数据非常重要,则不需要拘泥于 3 份,可以选择更多份备份。

如果需要备份的数据性质明显不同,则对于不同数据可以选择不同的方式。例如古早的照片可能不需要经常修改、查看,但需要稳定保持,这类冷数据就建议放在移动机械硬盘中(并妥善存放),再留有至少一份放在云盘(或者是 OSS)中。而作业可能需要经常修改、查看,因此建议做 6.2 的双盘备份,在本地同步/备份会比较方便。对于代码类数据,则建议使用 Git 进行托管,但也需要在本地进行备份重要的 Commit/Tag 以便在 Git 误操作时挽救。

6.1 本机另一块硬盘同步 + 1个云盘

本方案最为简便,但容错差,数据重要时请从 6.2 开始

321 构成

  1. 源数据
  2. 电脑中的另一块硬盘(注意:Windows 下多个盘符不一定是不同硬盘)中的文件夹,保持数据与源数据同步
  3. 定期上传到任何云盘(清华云盘等),最好能自动上传

成本

  • 当电脑为固态 + 机械组合的笔记本、且云盘有一定白嫖容量时,这种方案几乎 0 成本
  • 如果电脑只有一块硬盘,则参考 6.2
  • 可以选择清华云或一些付费云盘以得到更好的体验

优点

  • 便宜
  • 本机双盘同步较为方便
  • 适合需要频繁修改、时效性短的数据,例如作业等

缺点

  • 容错低,本机出意外可能导致两块硬盘同时损坏(概率不容小觑)
  • 需要确保能够定期备份到云盘,过时的备份约等于没有备份
  • 上云数据要注意安全性

6.2 外置硬盘 + 1个云盘

本方案适合大部分人使用

321 构成

同 6.1,将其中的另一块硬盘换成外置硬盘

依据数据重要性,可以选择使用多块外置硬盘,并混合使用固态、机械硬盘,也可以在 6.1 的基础上增加一块外置硬盘

成本

  • 外置硬盘成本,例如现在(2023-04) 1T 的 NVME SSD 可能只需要 400 元人民币(或更低),加上不到百元的移动硬盘盒即可
  • 移动机械硬盘价格更低,但鉴于其容易由于震动等物理因素损坏,不建议将其作为“移动”用途,而是将数据备份到里面以后直接静置保存。且机械盘可能买到叠瓦盘,性能堪忧,需要做足功课
  • 除非要备份的是比较冷的数据,否则不建议移动机械硬盘方案

优点

  • 成本低
  • 容错率相对 6.1 更高

缺点

  • 本地备份相对 6.1 更麻烦(需要外接硬盘)
  • 移动硬盘要注意安全性(丢失、机械硬盘损坏等)
  • 移动固态硬盘要避免太久不上电

6.3 多云

鉴于许多云盘都有一定的白嫖容量,可以在 6.2 的基础上多选择几个适合自己的云盘供应商。这样的好处是不用担心某个云盘挂了。

6.4 私有云

本方案成本、技术要求较高,因此只是在此罗列而不详细介绍。对于普通用户的重要数据,只需要本地多外置硬盘备份 + 多云备份,基本就万无一失了

Arch 休眠到交换文件

参考 Arch WikiArch简明指南 配置系统休眠到 swap file(ext4),配置完毕后无法正常休眠,问题如下

1. KDE 开始菜单不展示休眠选项

尝试手动休眠 systemctl hibernate,提示 "Not enough swap space for hibernation"

根据 Arch BBS,通过 systemctl edit systemd-logind.service 并在其中添加

# 注意添加位置,必须在文件中注明的两段注释之间,否则不会生效
[Service]       
Environment=SYSTEMD_BYPASS_HIBERNATION_MEMORY_CHECK=1

后重启 logind 服务 systemctl restart systemd-logind 即可

2. 休眠后立刻回到登录页面

休眠后查看日志 journalctl -n 1000,在其中查找 hibernate 相关记录,发现报了

Failed to find location to hibernate to: Function not implemented

怀疑是 hibernate 目标交换文件配置有误,检查后发现在获取交换文件 resume_offset 时,用 sudo filefrag -v /swapfile 命令查看的偏移如下:

Filesystem type is: ef53
File size of /swapfile is 34359738368 (8388608 blocks of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:        0..    6143:    4114432..   4120575:   6144:            
   1:     6144..   38911:    3997696..   4030463:  32768:    4120576:
   2:    38912..   71679:    3506176..   3538943:  32768:    4030464:
   3:    71680..  104447:    8224768..   8257535:  32768:    3538944:
   ...

physical_offset列的第一个值应当是 4114432,而我配置成了 4120575,修改后重新生成 grub.cfg 即可

Wechat

20240905 更新

上周某次系统滚动更新后,原先的 v3.8.0.33 由于是 x86 版本,会报 dll 错误,参考了网上的资料均未解决,而切到 x64 版本又有一些小 bug

看到 wiki 更新了重构版的 wechat-uos

发现体验还可以,没有各种字体 bug,唯一的缺点是不能撤回和引用

20240125更新

备忘目前完整的微信安装流程

  1. 装 wine-for-wechat 和 wine-wechat-setup
  2. https://github.com/tom-snow/wechat-windows-versions 找老版本的微信安装包, 例如 Wechat v3.8.0.33

    wine-for-wechat 会默认使用 prefix / 'drive_c/Program Files/Tencent/WeChat' 作为 WeChat.exe 的目录, 安装微信时需要注意, 旧版本微信可能会默认安装到 Program Files x86 下

  3. 用 wine-wechat-setup 安装微信, 会一并创建 ~/.local/lib/wine-wechat 这个 WINEPREFIX

如果需要改 dpi 等, 可以 wechat -c

Archive

更新: 现在使用 com.qq.weixin.spark 包的微信,然后将其用的 wine 替换成 wine-for-wechat

使用打包好的 Deepin Wine Wechat Arch,仓库中已经给出了详细的安装方法、字体更换等

Sway

从 i3 迁移到 sway 后, Deepin Wine Wechat 的微信窗口黑屏, 暂时没找到解决办法, 改用 wine-for-wechat 后解决

安装流程

  1. 安装 wine-for-wechat, wine-wechat 包, 均在 archlinuxcn 源上
  2. 微信官网下载 .exe 安装包
  3. 命令行 wechat -i /path/to/wechat_setup.exe 安装微信
  4. 安装完成后就能使用, 如果字体有问题, 参考 Ubuntu20.04 Wine 6.0 微信中文显示方块/方框, 本质上为如下几步
  • winetricks 安装所有字体和所需 dll
  • 改注册表

不过仍然没解决 emoji 为方块的问题

Trouble shotting

窗口阴影

换到 i3wm 后,混成器使用了 picom,此时使用 Deepin Wine Wechat 会发现整个窗口被灰色遮罩,且有弧形的黑色阴影,关闭 picom 后上述情况消失,因此判断是 picom 的问题

查阅 arch wiki 上 picom 条目发现,picom 可以针对窗口禁用半透明、阴影等特性,其规则可以细化到匹配窗口名称

首先通过 xprop 查询微信的窗口名:

# xprop
WM_NAME(STRING) = "WeChat"
...
WM_CLASS(STRING) = "wechat.exe", "Wine"

然后在 picom.conf 里添加如下规则:

~/.config/picom/picom.conf
# Specify a list of conditions of windows that should have no shadow.
#
# examples:
#   shadow-exclude = "n:e:Notification";
#
# shadow-exclude = []
shadow-exclude = [
  # ...
  "name = 'WeChat'",
  "class_g = 'wechat.exe'",
  "class_g = 'Wine'"
];

重启 picom 即可

字体发虚

打开 wine 设置

# /opt/apps/com.qq.weixin.deepin/files/run.sh winecfg

graphics 选项卡里调高DPI即可

部分 Emoji 为方块

尚未解决

i3wm 切换到 sway

复制配置

mkdir -p ~/.config/sway
cp ~/.config/i3/config ~/.config/sway/

重映射 CapsLock 到 Ctrl, 修改键盘重复速率

原来用的是 setxkbmap, 但这是针对 xorg 的

映射方法为在 sway config 中加入

input "type:keyboard" {
    xkb_options caps:ctrl_modifier
    repeat_delay 150
    repeat_rate 80
}

Deepin Wine Wechat 黑屏

Wechat

其他

  • 使用 wofi 替换 rofi
  • 使用 waybar 替换 polybar
  • 使用 swaybg 替换 feh

vscode 无法记住登录

装了 visual-studio-code-bin, 发现每次启动一个新的实例时都要求登录以同步设置, 即无法记住其他实例的登录

最后参考 Settings Sync in Visual Studio Code 解决了

Linux 上的 VSCode 依赖 gnome-keyring 来保存认证信息, 所以需要正确安装并配置 gnome-keyring, 我之前尝试过装 gnome-keyring 但最后还是没解决, 可能是因为当时装了另一个 keyring 包, 二者冲突了

具体步骤

  1. ~/.xinitrc 里添加如下行

    # see https://unix.stackexchange.com/a/295652/332452
    source /etc/X11/xinit/xinitrc.d/50-systemd-user.sh
    
    # see https://wiki.archlinux.org/title/GNOME/Keyring#xinitrc
    eval $(/usr/bin/gnome-keyring-daemon --start)
    export SSH_AUTH_SOCK
    
    # see https://github.com/NixOS/nixpkgs/issues/14966#issuecomment-520083836
    mkdir -p "$HOME"/.local/share/keyrings
    

    由于我在用 swaywm, 所以我把上面这些行加到了 sway 的 config 里, 最后也能 work

  2. 重新登录以加载 1. 的配置

  3. 安装 gnome-keyring etc., sudo pacman -S gnome-keyring libsecret libgnome-keyring

  4. 使用任何 gnome-keyring 的管理手段 (比如 seahorse), 解锁默认的 keyring 或者创建一个新的没上锁的 keyring. 在我的尝试里, 这一步如果不做, 启动 vscode 并登录账号时会提示新建 keyring, 新建时不要设置密码即可

  5. 启动 vscode

蓝牙鼠标在静止不动之后重新唤醒会卡顿

内核参数增加 btusb.enable_autosuspend=0 禁用自动休眠即可

自动认证 Tsinghua WiFi

在 Mac 连接到 Tsinghua/Tsinghua-5G 等需要认证的无线网时自动认证而无需经 Web 认证

0. 准备

下载

1. auth-thu

用于从命令行认证校园网

  • 给予可执行权限: chmod +x auth-thu.macos.arm64
  • 放到 $PATH 下并重命名为 auth-thu: cp auth-thu.macos.arm64 /usr/local/bin/auth-thu
  • 在 HOME 目录下创建其配置: touch ~/.auth-thu
  • 在配置内写入校园网账号密码 (这里只能明文): vim ~/.auth-thu
    {
        "username": "username",
        "password": "password"
    }
    
  • 尝试认证看正不正常
    > auth-thu
    2022-11-23 17:08:41 INFO auth-thu main.go:308 Currently online!
    

2. WifiWatch.app

直接运行 .app 即可,这个程序会在后台,当连接/断开 WiFi 时执行特定脚本

  • 连接时执行 ~/.wifiConnected
  • 断开时执行 ~/.wifiDisconnected (本示例用不到)

可以在系统设置里把这个 .app 加到开机启动项

3. .wifiConnected

连接 WiFi 时执行的脚本

写入如下内容 vim ~/.wifiConnected

#!/bin/bash
# arg1: SSID of network
# arg2: SSID of old network, if any

log=/tmp/auth-thu
if [[ "$1" =~ ^(Tsinghua|Tsinghua-5G)$ ]]; then
	for i in 4 3 2 1; do
		sleep $i
		connected=$(/usr/local/bin/auth-thu 2>&1 | tee -a $log | grep -E "online|Successfully" && echo 0 || echo 1)
		if [[ $connected == 0 ]]; then
			sleep $i
			/usr/local/bin/auth-thu 2>&1 | tee -a $log
		else
			break
		fi
	done
fi

然后给予可执行权限 chmod g+x ~/.wifiConnected

4. 尝试

  • 退出认证
  • 断开 WiFi 然后连接 Tsinghua/Tsinghua-5G
  • 检查 /tmp/auth-thu 里是否有 log
  • 检查 WiFi 是否正常认证

5. Trouble Shooting

WiFi 名目前是字符串匹配且只有 Tsinghua/Tsinghua-5G,如果需要其他的,加在 .wifiConnected

6. Reference

  • https://apple.stackexchange.com/questions/139267/run-program-if-connected-to-specific-wifi
  • https://github.com/p2/WifiWatch
  • https://github.com/z4yx/GoAuthing

5600x 超频

1. 平台

  • R5 5600x
  • 乔思伯 HX6200D 风冷
  • MSI B550I Gaming Edge Max Wifi
  • 铂胜白条 3000MHZ 8G * 2
  • RX584
  • 300w电源

装在蜂鸟 i100pro 这个 itx 机箱里

2. 参数

内存小超到了 3800 16-19-19-36 1.39V

这个电脑只用来打游戏,由于机箱没有风扇位,而且是下压式风冷,开 PBO2 之后 Aida64 烤 FPU 温度巨高,打游戏掉帧,索性锁频降压使用

全核锁 4.6GHZ,电压在主板里给 1.1375V,防掉压之类的全 Auto,室温 20 度

  • 开机箱盖,单烤 FPU 能稳定 10 分钟,温度稳定在 79 度
  • 关机箱盖,单烤 FPU 2 分钟后蓝屏,温度到 82 度

全核锁 4.6GHZ,电压在主板里给 1.1625V

  • 开机箱盖没测
  • 关机箱盖,单烤 FPU 8 分钟后黑屏重启,温度到 86 度

之后还是想关机箱盖用,考虑到打游戏负载也不会多高,暂时就用 4.6GHZ/1.1625V 的参数跑,打游戏的时候崩溃再说, 这个参数下:

  • CPU-Z 单核 633.9,多核 5005.6
  • 待机 41 度
  • 守望先锋:归来 2K 极高画质下 55 度,能稳定在 60FPS 以上

UPDATE:

  • 关闭超线程,参数修改为 4.575GHZ/1.1V,关机箱盖 Aida64 单烤 FPU 稳定 69 度,超过 15 分钟不崩溃,感觉是一个更合适的设置
  • 单核跑分 631,关超线程多核就不看了

Plex

针对 Official 的 Plex App 的配置备忘

  • 生成 claim token
  • use plex pass
  • 在 Truenas 里创建 plex 用户组和 plex 用户,给予他们媒体文件夹的权限,同时给予 apps 用户权限
  • Environment Variables for Plex 里添加 PLEX_UID 和 PLEX_GID,值为 Truenas 里对应用户和组的 ID
  • Plex Extra Host Path Volumes 里挂载目标文件夹

APP 持续 deploying 问题

最开始是 Plex 的 TrueCharts 版部署持续在 Deploying 状态, 以为是这个 APP 本身的问题, 后来发现 nextcloud 的 TC 版也有一样问题

最后通过检查 k3s 的 pod 状态发现, 这两个 APP 部署时均需要创建 PVC, 而创建 PVC 依赖容器的镜像一直 ImagePullBackOff, describe 查看日志后发现是网络问题 (k8s.gcr.io 不通)

在路由上增加代理之后可以解决, 另一个成功的方法是, 局域网设备开 clash(allow lan) 或者之类的代理, Truenas 网络配置 http proxy (http://addr:port), 然后重新创建 APP, 在拉镜像时可能仍然会失败, 但这时只要手动查看是哪个域名的镜像没拉成功 (以 k8s.gcr.io 为例), 然后在终端 http_proxy=http://addr:port curl k8s.gcr.io 即可

用 clusterissuer 自动申请 SSL 证书

无意外的话就能自动签发证书了,在其他 TrueCharts App 里填这个 cert 即可

M2 SSD 作为存储盘

使用 007revad 的脚本

在 ds918+, DSM 7.2 上测试

  1. 先跑 Synology_M2_volume 脚本, 跑完就可以直接建存储池了, 不用重启
  2. 然后跑 Synology_HDD_db 脚本, 用的 -nr flag
  3. 在计划任务里加一个 开机触发、root 运行的计划, 内容为 /path-to-script/syno_hdd_db.sh -nr
  4. 加一个备份任务, 定期把 SSD 的数据备份到 HDD 盘, 免得突然挂了

Plex

记录装 plex 的注意点,参考 群晖 Docker 安装 qBittorrent (DSM 7.2)

  1. 加一个计划任务、开机时以 root 运行 chmod 666 /dev/dri/*
  2. docker 安装 plex,参考 Plex in container manager on a Synology NAS hardware transcoding
  3. 我的 docker compose 如下,为 plex 单独创建了一个用户
services:
  plex:
    image: linuxserver/plex:latest
    container_name: plex
    network_mode: host
    environment:
      - PUID= #CHANGE_TO_YOUR_UID
      - PGID= #CHANGE_TO_YOUR_GID
      - TZ=Asia/Shanghai #CHANGE_TO_YOUR_TZ
      - UMASK=022
      - VERSION=docker
      - PLEX_CLAIM= #Your Plex Claim Code
    volumes:
      - /volume3/docker/plex:/config
      - /volume2/Movie:/media/Movie
      - /volume2/Series:/media/Series
      - /volume2/Music:/media/Music
      - /volume2/Video:/media/Video
      - /volume2/Anime:/media/Anime
    devices:
      - /dev/dri:/dev/dri
    security_opt:
      - no-new-privileges:true
    restart: always

服务启动后可以禁用一下 relay

qBittorrent

用 docker 安装, docker compose 如下

services:
  qbittorrent:
    image: linuxserver/qbittorrent
    network_mode: host
    container_name: qbittorrent
    environment:
      - WEBUI_PORT=10095    # Customize if needed
      - PUID=          # Change to your user ID
      - PGID=          # Change to your group ID
      - TZ=Asia/Shanghai    # Change to your timezone
    volumes:
      - /volume4/AppData/qBittorrent:/config/qBittorrent # 配置目录
      - /volume2/Download:/downloads # 数据目录
    restart: unless-stopped

如果有旧的种子信息需要导入, 直接把旧的 qBittorrent 配置目录复制过来就行, 主要是其中的 BT_Backup 文件夹

acme + letsencrypt 自签证书并自动更新

参考 群晖Synology使用ACME获取SSL证书

btrfs 修复

买的二手 nas,两条内存中有一条坏,由于开机时没 memtest 且能点亮,迁数据过程中一直没发现问题

中途报了个 checksum mismatch,还不以为然

然后某次重启之后一个存储池变成 Read-Only 了,但其实此时访问那个卷的数据都会 IO 错误

查资料看到 Btrfs修复

照着操作一遍之后重启就能用了,不过后续又报了几个 checksum mismatch,相关的照片确实也损坏了

selfhosted 整体结构

毕业在即,目前服务跑在校园网下,有公网 IP,但毕业之后大概率租房网络不会有这个条件。另外还要考虑离校后 BYR 下载的问题,经过几天的摸索得出了以下方案

背景

  1. 在家里跑一个 NAS,其上可能会跑一些服务

    如果租房不允许自己拉宽带,那么连光猫桥接都不一定能做,所以可以假设它一定没有公网 IP,可能会经过多层 NAT (运营商 NAT/家里路由器的 NAT)

  2. 在家里会有局域网设备需要访问 NAS,这个随便怎么都能实现
  3. 我自己可能需要在其他地方 (比如公司) 访问 NAS
  4. (本质同 3.) 其他人可能需要 (广域网的其他无公网 IP 设备) 需要访问 NAS 上的服务 (比如 Plex 串流),期望能够有一个不错的服务质量
  5. 最好有能力继续在 BYR 下载一些想看的资源

必要条件

  • 为了实现 3./4.,本质上需要一个内网穿透的手段,比如 frp 或者 vpn,经过测试 tailscale 的效果不错
  • 为了实现 5.,必须
    • 有一台 教育网/海外v6服务器,服务器延迟无所谓,但必须有不太小的带宽 (100Mbps 及以上为佳),流量最好无限制,或者足够多。教育网服务器可以找 THU 的同学嫖一个实验室的机位,因为校园网是给公网 IP 的,带宽够大、流量不限还不用花钱,缺点就是维护需要频繁请同学吃饭。海外v6服务器带宽满足要求,但便宜的机型通常有 1T 的流量限制
    • 有合适的通过该服务器访问 v6 的手段,比较方便的是 ssr

具体实现

互相访问

NAS - 公网Server - 其他设备 通过 tailscale 组网

任何其他设备都通过 tailscale 访问 NAS

Tailscale 带宽问题

因为 NAS 和其他设备可能都会经过 NAT,因此这里很可能做不到 direct 而是会变成 relay,延迟/带宽会非常差

如果只是 ssh 或 webui,relay 可能没什么问题,假设要 Plex 串流,就得想个增加带宽的办法,最通用的办法是在 公网Server 上搭一个 DERP

BYR 下载

通过 ssr 代理来做 4to6 来做下载效果似乎不是很好,而且很挑客户端,因此我采用了另一个方式:

  • Server 通过 tailscale + SMB mount NAS 的盘
  • Server 上跑 qBittorrent 等客户端,通过 SMB 下载到 NAS 里

由于 Server 有公网 IP,tailscale 一定是 direct,所以 SMB 的读写速度勉强能用

Arch as Router

背景

硬件是倍控 G31(Intel Gold 7505),本来想参考 PVE 跑一堆虚拟机的方案,但尝试过 PVE + openwrt + ikuai + debian(跑docker) 的组合之后,发现了一些问题:

  1. 最大的硬伤是这个机子有不错的核显,但不能显卡直通,因此一旦采用 PVE 的方案,基本核显就浪费了
  2. ikuai 做主路由的时候,IPv6 配置支持不全,如果用 openwrt 做主路由,又因为固件太多软件包太杂,导致配着配着就崩了(主要是因为不熟悉 openwrt),而且因为不熟悉,导致不是所有东西都 under control

理论上大部分 linux 都可以通过配置来当路由用,因为无非就是要拨号,转发,提供 DHCP/DNS 等服务罢了,区别就在于是不是开箱即用。但与其用一个开箱即用的、不太熟悉的 OS,不如用一个不开箱即用的、熟悉一点的 OS,至少出了问题知道怎么排查

看了一些文章之后,基本断定 Arch 是可以拿来做路由的,刚好自己也比较熟了,Arch Wiki 还有专门的 Router 页面,就决定用 Arch 了

安装系统

参考 archlinux 基础安装 即可,我按照其推荐的做了 btrfs,内核选了 linux-zen

可以再配一下 ssh,之后总会用到的,但路由器可能对外暴露,所以安全问题需要自行注意

配置路由功能

主要参考

我有四张网卡,其中第一张用来当 WAN,其余的用来当 LAN,WAN 约定称为 extern0, LAN 约定称为 intern0,1,2

网络管理通过 systemd-networkd 进行,DHCP server 用 dnsmasq 提供,DNS server 用 dnsmasq + smartdns 提供,防火墙和网络转发(NAT)用 firewalld 提供

1. 重命名接口

参考 Arch is the best router 将网卡名字改成刚才约定的,之后配置方便一点

修改

# /etc/udev/rules.d/10-network.rules
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="aa:bb:cc:dd:ee:ff", NAME="extern0"
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="ff:ee:dd:cc:bb:aa", NAME="intern0"
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="ff:ee:dd:cc:bb:ab", NAME="intern1"
SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="ff:ee:dd:cc:bb:ab", NAME="intern2"

重新加载

udevadm control --reload
udevadm trigger

然后 ip l 应该可以看到接口名

2. 配置各接口

extern

对于 extern0, 我们希望它能拨号或者从上游 DHCP 服务器获取 IP,由于目前用的校园网,所以按后者配置

# /etc/systemd/network/20-wired-external-dhcp.network
[Match]
Name=extern0

[Network]
DHCP=yes
IPv6AcceptRA=yes
IPv6PrivacyExtensions=yes

intern

对于 intern0,1,2,我们希望它们能被桥接到一起,这样使用任何一个接口都没有区别

# /etc/systemd/network/br_lan.netdev
[NetDev]
Name=br_lan
Kind=bridge
# /etc/systemd/network/10-bind-br_lan.network
[Match]
Name=intern*

[Network]
Bridge=br_lan

然后对于 br_lan 进行网络配置

# /etc/systemd/network/21-wired-internal.network
[Match]
Name=br_lan

[Link]
Multicast=yes

[Network]
Address=10.0.0.1/24 # router 的内网 IP 及网段
#MulticastDNS=yes # 打算用dnsmasq替代
#IPMasquerade=both # 如果启用,将会与 firewalld 冲突,因为它们都会修改 nftables

3. 配置 dnsmasq

安装

pacman -S dnsmasq

配置

# /etc/dnsmasq.conf
except-interface=extern0 # 排除extern0
expand-hosts      # 为 /etc/hosts 中的主机名添加一个域名
domain=foo.bar    # 允许DHCP主机的完全限定域名(需要启用“expand-hosts”)
dhcp-range=10.0.0.2,10.0.0.255,255.255.255.0,1h # 定义局域网中DHCP地址范围:从 10.0.0.2 至10.0.0.255,子网掩码为 255.255.255.0,DHCP 租期为 1 小时 (可按需修改)
port=0 # 禁用 dns 服务,如果不打算用 smartdns,可以将这个 port 设为默认的 53,然后添加诸如 server=8.8.8.8 的规则以指定 dnsmasq 的上游 DNS
dhcp-option=6,10.0.0.1 # 但是在 DHCP 时通告本机为 DNS server
# 设置默认网关
dhcp-option=3,10.0.0.1

启用

systemctl enable --now systemd-networkd

4. 配置 SmartDNS

参考 SmartDNS 即可

5. 网络转发

先启用内核的网络转发(需要重启):

# /etc/sysctl.d/30-ipforward.conf
net.ipv4.ip_forward=1
net.ipv6.ip_forward=1

然后安装 firewalld 并启用

pacman -S firewalld
systemctl enable --now firewalld

配置 NAT 规则 (参考 Arch Wiki Internet Sharing)

firewall-cmd --zone=external --change-interface=extern0 --permanent
firewall-cmd --zone=internal --change-interface=br_lan --permanent

firewall-cmd --permanent --new-policy int2ext
firewall-cmd --permanent --policy int2ext --add-ingress-zone internal
firewall-cmd --permanent --policy int2ext --add-egress-zone external
firewall-cmd --permanent --policy int2ext --set-target ACCEPT

# 重要:wiki里没有手动允许 dns, 导致dnsmasq无法响应请求, 需要手动添加
# 		 可能还需要添加 dhcp 等,因为它默认连内网的包都过滤,所以如果发现内网一些服务不通,先检查 firewalld
firewall-cmd --add-service=dns --zone=internal --permanent
firewall-cmd --add-service=dhcp --zone=internal --permanent

firewall-cmd --reload

这一步做完之后如果没问题那就没问题了(),其他设备连网口应该能正常上网了,dnsmasq 会响应设备的 DHCP 请求并分配 IP,设备 DNS 会走 Arch 的 smartdns,网络包会由 firewalld (底层是 nftables) NAT

其他配置

1. Auththu

校园网需要认证,通过 goauthing 实现,参考 https://github.com/z4yx/GoAuthing

2. 备份

参考 利用 Snapper 实现 btrfs 自动定时备份 btrfs 可以用 snapper, grub-btrfs 可以从快照启动,方便恢复系统

pacman -S snapper snap-pac grub-btrfs
systemctl enable --now grub-btrfsd
grub-mkconfig -o /boot/grub/grub.cfg

配置不容易,多备份,每次要改配置前手动快照一下

3. BBR

一个 TCP 拥塞控制算法 参考 Gist - Enabling BBR On Arch Linux 4.13+

4. DDNS

我是 aliyun 的域名 + aliyun 的 ddns,定时跑这个脚本就行 Github - SNBQT/aliyunddns

5. 静态 IP 租约

查看当前租约

cat /var/lib/misc/dnsmasq.leases

分配

# /etc/dnsmasq.conf
# 如果要让 dnsmasq 将固定 IP 分配给某些客户端,请绑定 LAN 计算机的 NIC MAC 地址:
dhcp-host=aa:bb:cc:dd:ee:ff,192.168.111.50
dhcp-host=aa:bb:cc:ff:dd:ee,192.168.111.51

6. UPS

断电了通知关闭 router 防止意外断电

Arch Wiki APC_UPS

兼容山特的 UPS (串口-USB通信)

7. cron

Arch 默认不带 cron,可以安装 cronie

8. 其他防火墙配置

端口开放及转发

firewall-cmd --zone=external --add-port=2222/tcp --permanent
firewall-cmd --zone=external --add-forward-port=port=2222:proto=tcp:toport=22:toaddr=127.0.0.1 --permanent

9. qBittorrent

安装配置参考 archlinux-install-qbittorrent-nox-setup-webui

10. Plex

安装可以用 snap

需要配置文件权限才可以添加资料库,见 plex-wont-enter-my-home-directory-or-other-partitions

11. Clash

Arch Linux Clash 安装配置记录

Trouble Shooting

遇到的最大问题就是,一开始 NAT 用了 networkd 自带的 IPMasquerade=both,然后想改成 firewalld,配置了很多遍都没成功,需要注意的点有:

  • 先关闭 firewalld
  • 关闭 networkd 的 IPMasquerade=both 之后,需要 restart networkd
  • 需要 nft flush ruleset 以清空规则,networkd 会在 nft 里写入 io.systemd.nat 这个规则表
  • 然后启动 firewalld 的服务,配置 zone 和 policy
  • 记住启用 internal zone 的 DNS/DHCP 等 service

Auth THU

THU 宿舍校园网需要网页认证, 不过有同学开发了 GoAuthing 这个认证程序, 在常规的 Linux 下可以直接开 crontab/service 定时认证, 但 QNAP 的 crontab 似乎有点问题, 我的解决方案是开了一个 Docker 容器跑认证程序

Dockerfile如下

FROM alpine:latest

COPY ./auth-thu /usr/local/bin/auth-thu
COPY ./.auth-thu /root/.auth-thu

# 这里输出 log 是为了之后验证 auth-thu 是否正确执行以 debug, 如果不需要 debug 则使用:
# RUN echo '*/1 * * * * /usr/local/bin/auth-thu auth' > /etc/crontabs/root
RUN echo '*/1 * * * * /usr/local/bin/auth-thu auth >> /var/log/auth-thu.log 2>&1' > /etc/crontabs/root

CMD crond -l 2 -f

用了 alpine 这个非常小的 linux 镜像, 使用方式为

  1. 创建 Dockerfile 并添加上面的内容
  2. 在 Dockerfile 同目录下下载 GoAuthing 的可执行文件并改名为 auth-thu, 同时赋以可执行权限
  3. 在 Dockerfile 同目录下创建 .auth-thu 文件, 填入 GoAuthing 的相关配置, 如用户名密码
  4. 在该目录下用 Dockerfile 创建镜像, 例如 docker create -t auththu:latest .
  5. 用创建好的镜像创建容器, 并设置其为自动重启(即随 docker 启动) docker run -d --name auththu --net=host --restart=always

QNAP Crontab 使用

  • crontab 配置文件在 /etc/config/crontab
  • 应用修改 # crontab /etc/config/crontab
  • 重启服务 # /etc/init.d/crond.sh restart

注意定时任务所用的脚本不能放在根目录下,因为 QNAP 每次重启都会重置根目录,建议放在 /share/homes/<username>/

QNAP Hlink Docker 使用

环境

  • QNAP 453B mini,事先安装好 Container Station,配置 SSH 登录
  • 我的 qBittorrent 下载文件均在 /share/Download 目录下,希望将里面的文件硬链到 /share/Media
    • 硬链目录不能跨盘,DownloadMedia 两个共享文件夹均在同一块硬盘同一个存储池上

部署

直接用 Container Station 部署时,hlink 会报 必须指定配置文件 的错,原因不明,因此这里用命令行部署

ssh 到 nas:

docker run -d --name hlink \
    -e HLINK_HOME=/share/Container/Docker/Hlink \
    -p 9090:9090 \
    -v /share:/share \
    likun7981/hlink:latest

将 nas 的 /share 挂载到 container 的 /share 下,同时指定 /share/Container/Docker/Hlink 为 hlink 的家目录。nas 的 9090 端口映射为容器的 9090 端口,即 hlink 的 WebUI

然后访问 :9090,创建配置文件和定时计划即可

Trouble shooting

目前(2022-10-17)hlink 的 WebUI 不支持账号功能,即一旦暴露到公网,任何人都可以访问该 WebUI,而我的 nas 直连校园网,自带公网 IP,因此需要考虑安全问题

暂时的解决办法是,考虑到一旦配置好 hlink 服务,便不太需要访问其 WebUI,因此可以在完成配置后用 iptable ban 掉 WebUI 的端口

#!/bin/bash

number=`iptables -t nat --line-numbers --numeric --list | grep dpt:9090 | awk '{print $1}'`
if [ -n "$number" ]; then
echo $number
iptables -t nat -D DOCKER $number
fi

qnap 的 iptable 规则每次重启后都会失效,因此可以将上述脚本加入 crontab

qBittorrent

安装

  1. App Center 添加软件源 https://www.qnapclub.eu/repo.xml
  2. 搜索 qBittorrent 安装

配置

账号

默认端口 6363,默认账号 admin,默认密码 adminadmin

进 设置-WebUI 修改端口、账号密码、语言等为想要的

HTTPS

在 设置-WebUI 选择使用 HTTPS 而不是 HTTP,证书和密钥设为域名证书的 .pem.key 文件的绝对路径

下载排队

在 设置-BitTorrent 启用或关闭 Torrent 排队和做种限制

校园网 IPv6 (北邮人 PT)

  • 设置-高级-qBittorrent 相关
    • 网络接口 改为有校园网 IPv6 的接口
    • 绑定到可选的 IP 地址 改为 所有 IPv6 地址
    • 如果 Tracker 显示 SSL 证书错误,则取消勾选 验证 HTTPS tracker 证书

LEDE 旁路由

背景

PS5 想翻墙, 想在 nas 里装软路由然后装个 clash 给 PS5 当旁路由

步骤

安装 LEDE

  1. 参考 威联通Docker教程 篇十:威联通NAS安装LEDE软路由,保姆级教程,手把手教您虚拟机安装openwrt旁路由 安装 lede, 这一步没什么坑
  2. 装好后, 由于 453bmini 有两块网卡, 第一块网卡我直接连了校园网做主网关, 第二块网卡连路由器给局域网用, 因此需要创建一个连接第二块网卡和 lede 虚拟机接口的虚拟交换机, 然后在 virtualization station 里配置虚拟机的网络为该虚拟交换机

安装 clash

  1. 使用的 clash 版本为 Koolshare-Clash-hack, 下载 release 的 tar 包用 lede 自带的离线安装
  2. 直接离线安装会提示非法关键字, 无法安装, 需要用这里提到的 hack 禁用关键字扫描规则, 核心是 ssh 上 lede 然后跑这个命令
    sed -i 's/\tdetect_package/\t# detect_package/g' /koolshare/scripts/ks_tar_install.sh
    

使用 clash

  1. 配置订阅链接, 启用 clash
  2. 需要在 clash 里开启需要走代理的设备的 IP
  3. 在 PS5 里修改网关为 lede 的 IP

mikrotik 端口转发保留 srcip

为了暴露家里的服务,做了 9443 上的端口转发,在设置访问规则时,发现即使设置了类似 LAN 免密这样的规则,从外网访问也会命中

应该是因为 dst-nat 配置错误导致 src-ip 被改成了网关 ip

参考 NAT IP don't show real visitors IP,将 masquerade 的 out-interface 设置为 wan 后解决

番茄肉酱(意面)

综合了以下三个方子

  1. 什么值得买 番茄肉酱意面味道不对?换一种番茄试试—波隆那肉酱意面做法
  2. youtube Easy Bolognese Recipe | Jamie Oliver
  3. 下厨房 番茄肉酱意面Bolognese意大利chef亲授

材料

以下材料适合一个 4-5L 的煮锅,大约能出 14 人份,一般是炖一次分装冷冻

材料可以等比例缩减,肉和菜的比例也可以随意调节(以炖锅能装下为准),以下是我常用的比例

主料

不可以少的材料,三种蔬菜体积差不多就行

  1. 牛绞肉 + 猪绞肉 1kg,通常 1:1
  2. 整颗番茄罐头(茄意欧) 400g * 2罐
  3. 洋葱一大个(半斤)
  4. 西芹两根
  5. 萝卜两根
  6. 黑胡椒(现磨),油,盐

配料

都是增加风味的,我是全都放了,没有的话做的时候略过即可

  1. 培根两片
  2. 大蒜
  3. 意式混合香料(比如可达怡意式混合香料)
  4. 肉桂粉
  5. 迷迭香
  6. 肉豆蔻
  7. 番茄膏(茄意欧)

做法

全部看完再开始操作,具体操作可以参考那三个方子(因为这里没有图)

1. 准备材料

三种蔬菜剁碎备用,有切碎机/绞肉机最好,没有的话参考方子 1. 切

  • 有绞肉机的话可以在炒肉的时候再绞,否则提前切好
  • 混在一起就行,三种蔬菜会一起炒

蒜粒的话剁碎,有迷迭香的话把叶子薅下来剁碎,有培根的话切小粒

2. 炒

最好是直接在最终炖煮的锅里炒,因为炒的时候会有点糊底,加水炖能把糊底的部分溶解掉

全程需要盯着翻炒,避免糊

锅热加油,油多点,至少要覆盖整个锅底,油热炒蒜、培根和迷迭香,中小火别烧焦

出香味后先加牛绞肉炒

  • 炒肉是一定会先出很多水的,要有耐心等水都挥发掉,肉才会开始焦黄,焦黄才会香
  • 一直搅拌翻炒,微微粘底无所谓,但别烧糊

等牛肉炒出来的水挥发掉一部分、肉变成淡褐色后再加猪肉一起炒,同理会先出很多水

等肉炒出来水都挥发得差不多了,加黑胡椒和盐,黑胡椒往死里加,盐正常用量,继续炒到肉变成焦褐色,参考方子 1. 里的图片

  • 这一步应该是整个酱肉味的关键,耐心炒,可能需要十几二十分钟

肉到位后加三种蔬菜碎(蔬菜单独炒或和肉一起炒都行),蔬菜碎也会炒出水,同样需要炒到水挥发干,蔬菜微焦,才会有味道

全都炒到位这一步就完成了

3. 炖

两罐番茄倒下去,再用番茄的罐子装水(两罐)加下去

加番茄膏,肉桂粉,肉豆蔻粉,意式混合香料,黑胡椒

搅匀,之前有糊底的话用勺子在底下铲几下铲干净

大火烧开转最小火,炖至水收干到酱变得略浓稠即可,一般需要一个多小时。我一般会开盖炖(因为要一直搅)

缓慢多次加盐,到口味合适,盐少了吃的时候可以再加,盐多了不好救,以及如果吃的时候要加帕玛森,由于帕玛森很咸,这里的盐需要适当少点

可以加点糖提鲜

这一步需要注意的是

  1. 由于固体部分会在炖的时候沉底,一定要一直搅拌(搅的时候用勺子刮到锅底),否则就会粘底烧糊
  2. 锅越薄(不锈钢/铝)搅拌越频繁,锅越厚(铸铁/陶瓷)搅拌可以越少,但几分钟至少需要搅一次
  3. 如果间隔太久固体已经沉底,搅的时候可能会有沸腾的泡泡导致飞溅,要防止烫伤

炖到位了分装冷冻即可

番茄牛腩

材料

  1. 牛腩 1000g
  2. 番茄 600g
  3. 番茄碎罐头 400g
  4. 洋葱半个
  5. 香料:姜、大葱、大蒜、桂皮八角香叶
  6. 调料:盐、冰糖、生抽、老抽、黄酒

步骤

顺序

  1. 牛腩处理:切块、浸泡、焯水、炒糖色
  2. 香料炒香
  3. 番茄洋葱炒熟

1. 备菜

  1. 牛腩切块,浸泡
  2. 洋葱切小片备用
  3. 番茄去皮或者不去,切滚刀块,如果希望成品里有成型番茄,可以取一小部分切成大角
  4. 姜切片,蒜切末,大葱切片,香料备用

2. 牛腩焯水

冷水没过牛肉下锅,少量姜片黄酒,大火煮并捞出浮沫直到干净。煮开后 2min 关火

焯水的水留下备用,牛肉捞出沥干

3. 炒肉

冷锅冷油下冰糖,炒化熬到冒泡

下牛腩炒上色微焦后,下所有香料

香料炒香后烹黄酒,水干后挪入煮锅,和焯水的液体一起开炖,期间加入生抽老抽等

4. 炒蔬菜

如果希望有成型番茄,切成大角的部分不炒备用

油热下洋葱,炒到微微透明,不需要炒太过

下番茄滚刀块,下少量盐,炒到出沙出汁

炒完后加到炖锅里一起炖

5. 炖

烧开后小火加盖炖,总时长1.5 - 2h

如果需要成型番茄,在出锅前十几分钟加备用的番茄

吐司

2023/01/26 制作

参考

材料等

450g 的吐司模用料

  1. 250g 高筋粉
  2. 75g 全蛋液
  3. 90g 牛奶
  4. 30g 白砂糖
  5. 25g 黄油
  6. 5g 干酵母
  7. 3g 盐

步骤

  1. 干酵母、黄油、盐除外,所有材料混合均匀,吸收至不见干粉,成团,密闭冷藏 30min - 12h,越长越好

    这一步可以让面团水合,并降低面温,方便揉出组织。因此时间久一点比较好

    制作时,实际冷藏了 4h 左右。由于用的砂糖是粗砂糖、蛋液没有打得很匀、天气较冷等原因,面团混合得不够好,导致水合不充分。之后制作可以混合得更充分一些再静置

  2. 准备好酵母、黄油和盐,酵母可以略加水(5g)搅拌成膏状。为了避免提前发酵,应控制酵母温度。之后揉面时面团也应控制在较低温度(28摄氏度-)
  3. 取出面团,揉面
  4. 揉面
    • 揉面大抵分成两步,先是混合所有材料,然后是充分揉搓直到出现手套膜

      混合材料阶段,手法是

      • 切块:将面团切块以增大表面积,然后均匀地加上某一材料,然后将所有块重新搓成一块
      • 揉搓:用一手固定面团位置,另一手将面团外推,类似在搓衣板上搓毛巾,将整个面团搓成扁扁的一坨后重新折叠收集成一坨,重复。直到添加的材料消失

      重复上述步骤直到所有材料混合均匀,材料的加入顺序为:盐、酵母、黄油

      然后是揉搓阶段,由于这一步耗时较久且面团长时间接触手,应当注意控制面温

      • 面团初始为一坨,同样是一手固定,另一手将其用力往外搓,搓成一滩后重新折叠收集成一坨。重复搓
      • 可以不时拾起面团用力向下摔到桌面,摔成长条后重新折叠成一坨

      重复上述两个步骤,总之就是不断让面团从一团变成一滩或是一条,然后复原

      等到面团不再粘手或粘桌面时,尝试在面团上撕下一小块,撕的手感类似于撕橡胶,有点筋道的感觉,则可以尝试拉膜,如果能拉出较薄的膜就差不多了

  5. 发酵
    • 整形:将面团均匀分割成 3 至 4 份,每份擀成宽度均匀的长薄片,然后从一端开始卷成一卷。然后将这个卷按长的方向(即擀面杖垂直于面团卷这一圆柱体的高)重新擀成薄片,再卷成卷。卷好后直接并排均匀放置到吐司模中,卷的长边与吐司模的短边平行
    • 发酵:密封吐司模,在合适的环境中发酵至吐司模 7-8 分满

      我采用单次发酵法,时间是冬天,发酵较慢。将吐司模密封放置在 10-20 摄氏度左右的烤箱内,花了 3.5h 左右发酵到预期高度。有温湿度计的可以看一些发酵指南精细控制。不再赘述。期间注意多检查面团状态

  6. 烤制
    • 密封吐司模
    • 柏翠烤箱,上下管 160 摄氏度,预热 10min,烤 30min
  7. 烤完后立刻脱模,侧向静置冷却

贝果

2023/01/28 制作

参考

材料和步骤均参考该帖子,这里只记录注意点

注意

  1. 在原配方的基础上,面粉的吸水性各有不同,比如这次做的时候按原方的水量揉出来的面团非常干,导致后续的整形等都不容易,之后可以适当增减水量
  2. 面团需要揉到扩展阶段,但不需要出手套膜,等面团变得光滑且有韧性应该就差不多了
  3. 整形的时候面团需要适当擀得大些均匀些,折叠之后要把气泡排空,卷起时注意接缝的密闭,否则膨胀之后非常容易开裂
  4. 圈可以适当做的大些,面团的膨胀空间很大,圈太小最后中间的洞就没了
  5. 做的时候只关注上侧有没有焦,结果底被烤焦了。需要注意这种看不见的地方

巴斯克

Ref 日本名店配方公開 流心巴斯克芝士蛋糕┃Lava Basque Cheesecake, Famous Japanese shop's recipe

材料

6寸圆模

  • 奶油奶酪 300g
  • 砂糖 60g (原配方 90g 有点太甜)
  • 全蛋 2个
  • 蛋黄 1个
  • 淡奶油 165g
  • 玉米淀粉 10g

步骤

  • 混合
    • 奶油奶酪-糖-蛋液-淀粉-淡奶油
  • 模具垫油纸, 过筛进模具
  • 中下层 230摄氏度 22 分钟

斑斓椰汁糕

简介

马蹄粉 + 斑斓粉版的斑斓椰汁糕,两种颜色,每种颜色需要调一个生熟浆,最后一层一层蒸熟

材料

调生熟浆的步骤是,生浆分两次加入离火沸水,第一次搅成糊状称之为熟浆,再将其余生浆加入,搅拌均匀,称之为生熟浆

配方刚好用掉一罐 400ml 椰汁和一盒 250g 马蹄粉

椰汁用的金牌高达,马蹄粉用的洲星

斑斓浆

液体需要分成两部分

    • 370g 左右斑斓汁(10g斑斓粉+360g水+3g小苏打)
    • 130g 马蹄粉
    • 一勺椰浆
    • 80g 糖(已减糖, 原配方100g)
    • 300g 水

椰汁浆

液体需要分成两部分

    • 400ml 椰浆(也许被挖走一勺了)
    • 120g 牛奶
    • 120g 马蹄粉
    • 100g 糖(已减糖, 原配方120g)
    • 120g 水

步骤

1. 调生熟浆

每种颜色都需要调一个生熟浆,步骤为糖水煮到沸腾,离火加入一勺生浆快速搅拌成糊,加入剩余生浆搅拌均匀

2. 蒸

选择合适的容器入锅蒸,选择合适的容器定量舀浆保证层次均匀

容器如果不是不沾的可以抹油

先加绿浆,加完可以晃容器铺平,每层3分钟直到凝固,最后一层5分钟

完全冷却后切块

半熟芝士(仿好利来)

Ref 半熟芝士蛋糕 | 完美复刻好利来

这个方子讲得很全,但是步骤太多不好阅读,因此自己整理一个简化版。不包括蛋糕底部分

材料

大约能做十几个,鸡蛋为 60g 大小

蛋黄糊

  1. 奶油奶酪 200g
  2. 黄油 22g
  3. 蛋黄 2个
  4. 牛奶 140g
  5. 糖 16g
  6. 玉米淀粉 11g

蛋白霜

  1. 蛋白 1个
  2. 糖 20g
  3. 柠檬汁

步骤

  1. 黄油奶油奶酪混合,放微波炉软化
  2. 蛋黄、砂糖、淀粉按顺序混合
  3. 牛奶煮到冒小泡,缓慢倒入 2 中(避免烫蛋黄)
  4. 混合物小火加热,不断搅拌,微微粘稠后离火,搅成酱状
  5. 趁热和奶油奶酪混合,冷却
  6. 预热烤箱,上200下100,15-20分钟,烤盘加水
  7. 蛋白打发湿性偏干
  8. 混合,入烤箱最下层
  9. 时间到后闷10分钟