TimothyQiu's Blog

keep it simple stupid

自旋锁、互斥器、条件变量及读写锁

自旋锁(spinlock)很好理解。对自旋锁加锁的操作,你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
}

只要没有锁上,就不断重试。显然,如果别的线程长期持有该锁,那么你这个线程就一直在 while while while 地检查是否能够加锁,浪费 CPU 做无用功。

仔细想想,其实没有必要一直去尝试加锁,因为只要锁的持有状态没有改变,加锁操作就肯定是失败的。所以,抢锁失败后只要锁的持有状态一直没有改变,那就让出 CPU 给别的线程先执行好了。这就是互斥器(mutex)也就是题目里的互斥锁(不过个人觉得既然英语里本来就不带 lock,就不要称作锁了吧)。对互斥器加锁的操作你可以认为是类似这样的:

while (抢锁(lock) == 没抢到) {
    本线程先去睡了请在这把锁的状态发生改变时再唤醒(lock);
}

操作系统负责线程调度,为了实现「锁的状态发生改变时再唤醒」就需要把锁也交给操作系统管理。所以互斥器的加锁操作通常都需要涉及到上下文切换,操作花销也就会比自旋锁要大。

以上两者的作用是加锁互斥,保证能够排它地访问被锁保护的资源。

不过并不是所有场景下我们都希望能够独占某个资源,很快你可能就会不得不写出这样的代码:

// 这是「生产者消费者问题」中的消费者的部分逻辑
// 等待队列非空,再从队列中取走元素进行处理

加锁(lock);  // lock 保护对 queue 的操作
while (queue.isEmpty()) {  // 队列为空时等待
    解锁(lock);
    // 这里让出锁,让生产者有机会往 queue 里安放数据
    加锁(lock);
}
data = queue.pop();  // 至此肯定非空,所以能对资源进行操作
解锁(lock);
消费(data);  // 在临界区外做其它处理

你看那个 while,这不就是自己又搞了一个自旋锁么?区别在于这次你不是在 while 一个抽象资源是否可用,而是在 while 某个被锁保护的具体的条件是否达成。

有了前面自旋锁、互斥器的经验就不难想到:「只要条件没有发生改变,while 里就没有必要再去加锁、判断、条件不成立、解锁,完全可以让出 CPU 给别的线程」。不过由于「条件是否达成」属于业务逻辑,操作系统没法管理,需要让能够作出这一改变的代码来手动「通知」,比如上面的例子里就需要在生产者往 queuepush_back 后「通知」!queue.isEmpty() 成立。

也就是说,我们希望把上面例子中的 while 循环变成这样:

while (queue.isEmpty()) {
    解锁后等待通知唤醒再加锁(用来收发通知的东西, lock);
}

生产者只需在往 queuepush_back 数据后这样,就可以完成协作:

触发通知(用来收发通知的东西);
// 一般有两种方式:
//   通知所有在等待的(notifyAll / broadcast)
//   通知一个在等待的(notifyOne / signal)

这就是条件变量(condition variable),也就是问题里的条件锁。它解决的问题不是「互斥」,而是「等待」。

至于读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。读写锁不需要特殊支持就可以直接用之前提到的几个东西实现,比如可以直接用两个 spinlock 或者两个 mutex 实现:

void 以读者身份加锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 += 1;
    if (rwlock.当前读者数量 == 1) {
        加锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以读者身份解锁(rwlock) {
    加锁(rwlock.保护当前读者数量的锁);
    rwlock.当前读者数量 -= 1;
    if (rwlock.当前读者数量 == 0) {
        解锁(rwlock.保护写操作的锁);
    }
    解锁(rwlock.保护当前读者数量的锁);
}

void 以写者身份加锁(rwlock) {
    加锁(rwlock.保护写操作的锁);
}

void 以写者身份解锁(rwlock) {
    解锁(rwlock.保护写操作的锁);
}

如果整个场景中只有一个读者、一个写者,那么其实可以等价于直接使用互斥器。不过由于读写锁内部是至少需要用一把锁来保护当前读者数的,所以,如果你的临界区很小,读写锁相比一般的锁并不能带来很大的优势,甚至可能性能更低。

另一方面,读写锁要真正发挥效能,条件也比较麻烦。比如实际的读写锁通常不用例子里两把锁的实现,而是用一把锁、一个条件变量来实现,好处是可以缓解写者饥饿的情况(一旦有写者在等锁,后续读者都需要等写者离开后才能继续),但这样一来,如果读者的临界区没有明显小于写者的临界区,阻塞情况可能会变得比较不理想……

所以你可以认为读写锁是针对某种特定情景的「优化」。不是说不要用读写锁,而是读写锁往往没有看上去那么理想。个人建议是可以优先用 mutex,如果遇到瓶颈后可以选择替换为读写锁,看看能否带来性能提升。

以上。

std::error_code 和它的朋友们

前几天看 API 文档时候遇到了 std::error_code 这个东西,当时以为是 errno 的 Alias,后续查阅文档才发现并没有那么简单。

system_error

std::error_code 包含在 <system_error> 头文件中,相关的共有四个组件:

std::system_error 是一个继承自 std::runtime_error 的异常类,用以表示与 OS 交互时得到的以错误码形式返回的错误。因此除了提供标准的 what() 函数外,它还额外暴露了一个返回 std::error_codecode() 函数。同时,因为必须提供错误码,它既不提供空构造、也不提供接受字符串的构造函数。

从名字上就可以看出来(同时也是当时的提案 N2241 中指出的),整个 <system_error> 头文件都是为实现这个异常而生的。STL 有意将 std::error_code 作为 std::system_error 的附属品,而非单独的错误处理机制。

std::error_code

STL 中早期类似的错误码实现需求来自(C++17 才正式进入标准的)文件系统 API,当时的实现认为错误分类应在 errno 和操作系统原生错误码上二选一。但由于 STL 中网络、各种奇形怪状的 Boost 库的陆续加入,大家急需一个可扩展的错误码表示方案,才演变成现在的 std::error_code

std::error_code 顾名思义表示错误码,是由一个 int 型的 value 和一个 std::error_category * 型的 category 组成的值类型类。

之所以在 value 外还需要保存一个 category,是因为即使是同样的错误码,在不同的库或者场景下表示的意义可能不同。同样是 42,在一个库的错误码中可能表示「文件不存在」,在另一个库中可能就表示「DNS 解析失败」。如果你熟悉 Cocoa 的那一套,那么这就类似 NSErrordomain 属性。

此外,虽然没有明说 value 在等于 0 时表示无错误,但整个系统就是建立在这样的假设上的,例如:

构造 std::error_code

std::error_code 的构造函数共有三种重载:

前两种没啥好说的,第三种却值得推敲。

template<class ErrorCodeEnum>
error_code(ErrorCodeEnum e) noexcept;

此处的 ErrorCodeEnum 只是名字上说是枚举,但其实只要是用户定义类型就行(比如 enum class / enum / class),所以理论上可以从异常直接构造 std::error_code

通常,如果构造函数只接受一个参数,那么我们推荐将它标记为 explicit 以免发生不必要的隐式转换。但这个函数不然,它要的就是让原始的 ErrorCodeEnum 可以隐式转换为 std::error_code,从而实现这两者的直接比较。

catch (std::system_error const& e) {
    if (e.code() == std::errc::invalid_argument) {
        // blah blah blah...
    }
}

当然,为了避免从任意类型的值都能搞个 std::error_code 出来,此重载只有当 std::is_error_code_enum<ErrorCodeEnum>::valuetrue 时才会有效。所以你自定义错误码枚举时,需要在 std 命名空间中特化此模版。

#include <iostream>
#include <system_error>

enum class YourErrorCode {
    kSuccess = 0,  // 别忘了 0 应该表示无错误
    kNetworkError,
    kBadRequest,
    kServerError,
};

// 特化模版,启用对应的重载
namespace std {
template<>
struct is_error_code_enum<YourErrorCode>: true_type {};
}

// 提供工厂函数
// 工厂函数不必要写在 std 中
std::error_code make_error_code(YourErrorCode code)
{
    return {
        static_cast<int>(code),
        std::generic_category(),  // 这里暂时用自带的 category
    };
}

int main()
{
    std::error_code e = YourErrorCode::kBadRequest;
    std::cout << e << '\n';  // 自带一个输出流的重载
}

当然,如果后续 C++ 中终于引入 Concept 的话,这个重载应该就没有这么复杂了吧。

std::error_category

前面说到 std::error_code 里保存的其实是指向 std::error_category 的指针,而非对象。这是因为这个类就是应该被当作单例来用的,实际也只能这么用才对,因为它的 operator=() 是通过直接比较 this 指针来实现的。

此外,std::error_category 还是个纯虚类,你必须实现的纯虚函数有:

STL 自带 std::error_category 的几个子类,很好地示范了「如何正确使用纯虚类」:不暴露具体的子类,而是暴露工厂函数,只通过接口(纯虚类)访问子类。

// 得到的都是 std::error_category const&
auto const& gec = std::generic_category();
auto const& sec = std::system_category();

std::error_condition

2007 年,<system_error> 正式进入标准前夕,委员会对 N2241 提案给出了一些意见,其中包括一条

Obscure the distinction between system-specific errors and the general and portable notion of an error condition.

于是,为了区分「系统相关的错误」和「平台无关的错误」,std::error_condition 诞生了。

因此你可以看到,std::error_condition 是一个与 std::error_code 除了语义几乎没有差别的东西。从库作者的角度,你可以理解为封装底层细节时用 std::error_code,而对外暴露接口时推荐使用 std::error_condition

一起玩

std::error_conditionstd::error_code 虽然是两个独立的类,但它们可以通过 std::error_category 连接在一起。

这两个类的对象互相比较时,STL 提供了对应的 operator=() 等函数的重载,通过调用双方的 category().equivalent(other) 来比较。只要任何一方的 category 认为对方与自己等价,两者就会被判为相等。

// 卖个萌 :-P
enum class MyErrorCondition {
    kChenggong,
    kWangluoCuowu,
    kQingqiuCuowu,
    kFuwuqiCuowu,
};

class MyErrorCategory: public std::error_category
{
public:
    // 还记得 std::error_cateory 是个单例么?
    static MyErrorCategory const& instance() {
        static MyErrorCategory instance;
        return instance;
    }

    char const *name() const noexcept override {
        return "MyErrorCategory";
    }

    std::string message(int code) const override {
        return "Message";  // 偷个懒
    }

    bool equivalent(std::error_code const& code, int condition) const noexcept override {
        // 理论上你用不着在这里处理 code.category() == this->instance 的情况

        // 因为是个单例,所以某些情况下不得不用这么绕的办法来拿
        auto const& yourErrorCodeCategory = std::error_code(YourErrorCode{}).category();

        if (code.category() == yourErrorCodeCategory) {
            switch (static_cast<MyErrorCondition>(condition)) {
            case MyErrorCondition::kChenggong:
                return code == YourErrorCode::kSuccess;
            case MyErrorCondition::kWangluoCuowu:
                return code == YourErrorCode::kNetworkError;
            case MyErrorCondition::kQingqiuCuowu:
                return code == YourErrorCode::kBadRequest;
            case MyErrorCondition::kFuwuqiCuowu:
                return code == YourErrorCode::kServerError;
            }
        }
        return false;
    }
};

// error_condition 同样需要特化模版启动重载
namespace std {
    template<>
    struct is_error_condition_enum<MyErrorCondition>: true_type {};
}

// error_condition 同样可以通过工厂函数构造
std::error_condition make_error_condition(MyErrorCondition code)
{
    return {static_cast<int>(code), MyErrorCategory::instance()};
}


int main()
{
    std::error_code code = YourErrorCode::kNetworkError;
    std::error_condition condition = MyErrorCondition::kWangluoCuowu;
    std::cout << (code == condition) << '\n';
}

看上去有点绕,但由于通常 std::error_code 是底层接口定死的,而你所做的只能是用 std::error_condition 来适配的情况来说,还是很合理的。

还是 STL 轻松,直接抛 std::system_error 给你。std::error_code 甩脸,要抽象你自己去用 std::error_condition 搞,哈哈。

顺带一提

上面已经提到此处的 std::system_error 异常与 std::error_code 的关系。事实上,至少在提出引入 std::error_code 提案的作者看来,STL 乃至 C++ 中推荐的错误汇报方法还是异常。由于存在与操作系统等底层 API 的交互,才引入了「从 API 返回的错误码构建异常」以及「将异常转换为错误码传给 API」这样的需求。

当然,为了方便统一范式,便于不支持异常的系统使用文件系统 API。C++17 才正式进入标准的文件系统 API 也添加了「默认抛异常,但额外传一个 std::error_code 就不抛异常」的机制。

以上。

从 feedly 投向 inoreader

当年 Google Reader 拜拜后,我也陆续尝试了不少国内国外的替代品。当时选择了相对不错的 feedly,但因为不像 GR 一样存在感强烈,常常忘掉它的存在。最近整理阅读渠道,又把 feedly 拾起来,就感觉到了 feedly 的不足。

首先便是基本功能收费,无法理解为啥作为一个 RSS 阅读器,feedly 连在自己的所有 feed 中搜索都是需要高级会员才能解锁的功能。

其次是 feedly 近几个月来隐藏已读文章的功能都是失效的。无法看到清爽的文章列表界面,这对于我这种强迫症来说简直要命。

另外,feedly 取消了几乎是 RSS 阅读器必备的星标功能,转而加入了「稍后阅读」按钮。虽然功能都只是一个旗标,可以将已标记的文章单独列出,但「星标」的语义可以由用户自己赋予;而「稍后阅读」却已经框死了语义,将其另作他用总让人有一种负罪感。于是我感觉很不舒服。

当然最令人发指的是 Android 客户端自带的「发送到 Pocket」功能,居然是需要你提供 Pocket 的用户名和密码的。既然网页版都实现了基于 OAuth 的 Pocket 授权,为啥客户端版本的反而要用这么「不安全」的方法,无法理解……曲线救国又需要「菜单 / 分享 / Add to Pocket」 这么啰嗦的步骤,所以干脆再见吧。

于是最近又尝试了一圈市面上仅存(诶)的几款 RSS 阅读器,客户端能够支持隐藏已读文章、支持星标(废话)、抓取速度好、又有比较好的 Pocket 支持的,大概就是 Inoreader 了。功能上,Inoreader 几乎是 Google Reader 传承而来的,也有丰富的快捷键支持。缺点是界面相比 feedly 感觉要不那么「现代」一点 :)

目前只用了 Inoreader 一小段时间,暂时没有特别的惊喜和苦恼。有一个有趣的用法是它可以将 Twitter / Google+ 当作 RSS 订阅,如果有合适的目标账号,应该还是很有用的。

以上。

自定义 Xcode 文件模板

几年前初次接触 Xcode,就觉得它的默认的代码模板很不好:

当时毕竟还是 Too Young,刚接触 OS X,照着奇怪的教程跑去 /Applications/Xcode.app 里乱改一气,虽然达到了目的,但总觉得有些怪味道,就不了了之了。

最近终于有机会重新折腾一下 Xcode,就又查了一下相关的内容。不得不说,Xcode 这方面文档真是太不友好了 =,=

自定义文件模板

/Applications/Xcode.app 里是应用自己的原生内容,不应该擅自修改。要修改文件模板,应该把对应的位于 /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates.xctemplate 模板文件夹复制到 ~/Library/Developer/Xcode/Templates 改个名字后,再加以修改。

# 创建文件夹结构(默认没有 Templates 及往后的文件夹)
mkdir -p ~/Library/Developer/Xcode/Templates/File\ Templates/Source

# 复制需要修改的 .xctemplate 文件夹
# 这里是在 Source 分类下,将 Swift File 模板复制为 Empty Swift File 模板
cp -R /Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File\ Templates/Source/Swift\ File.xctemplate/ ~/Library/Developer/Xcode/Templates/Source/Empty\ Swift\ File.xctemplate/

这样,修改 Empty Swift File.xctemplate/___FILEBASENAME___.swift 就可以把讨厌的注释去掉了。

这里没法修改原有模板,也没法覆盖。即便使用与 Xcode 自带模板相同的名称,也只会在新建文件的模板选择对话框里出现两个同名的模板,非常蛋疼。

细枝末节

由于相关文档不足,我又有点强迫症,于是尝试了一些东西,比如上面说的「使用与 Xcode 默认模板相同的名字没法覆盖默认模板」。

上面路径中的 ~/Library/Developer/Xcode/Templates 为用户自定义模板的根目录,这个目录下的 .xctemplate 文件夹会被 Xcode 加载。Xcode 模板中的「分类」与此处 .xctemplate 的父目录有关。

虽然最后两种在效果上没什么区别,但还是推荐使用最后一种,与 Xcode.app 中的目录结构对应起来,会比较方便管理。

另外,使用向导填写模板参数的高级用法需要查找官方 Reference,不过粗略找了一下也没有找到官方说明,很是沮丧 :( 不过反正目前用不到 :)

Python 3 的 surrogateescape

新项目中第一次开始作死使用 Python 3,在测试中遇到一件奇怪的事情。

系统中有一个邮件发送模块,直接在命令行中手动跑 Worker 的话邮件可以成功发送,而一旦用 supervisor 运行则无法发送邮件。日志中显示在邮件发送库中抛出了如下异常:

UnicodeEncodeError: 'utf-8' codec can't encode character '\udce6' in position 0: surrogates not allowed

Surrogate

所以,日志里这个 surrogate 是个啥?

Surrogate 是 Unicode 中位于 BMP 外的一组不会有对应字符的码位,Python 3 中使用这些码位来「代表」无法编码的字节。

如果你明白「锟斤拷」是怎么来的,那么就一定能理解 Python 的 replace 编解码错误处理机制:无法解码的字节会被替换成 U+FFFD,无法编码的码位会被替换成 ?。这样做有一个明显的缺点就是不可逆。Python 3 中新增的 surrogateescape 则是一种可逆的错误处理机制,利用 Surrogate 码位保存无法解码的字节,编码时则将其还原为对应的原始字节。

例子中的报错正是因为 0xE6 无法使用 ascii 解码,而被解为了 U+DCE6

'\udce6'.encode('ascii', 'surrogateescape')
>>> b'\xe6'
b'\xe6'.decode('ascii', 'surrogateescape')
>>> '\udce6'

知道了原因,用这种方法解码异常信息中的完整字符串就找到了罪魁祸首:邮件发送者的配置。

Python 3 的环境变量与编码

邮件发送者是使用 显示名称 <邮箱地址> 的格式在环境变量中设置的,显示名称部分包含了汉字。

Python 3 中,使用 os.getenv 或者 os.environ 获得到的环境变量的值都是 str 类型。在类 Unix 系统中,解码过程是使用 sys.getfilesystemencoding()'surrogateescape' 错误处理来实现的。而 sys.getfilesystemencoding() 的取值与 nl_langinfo(COODESET) 相关,即 LC_CTYPE 环境变量。

所以,导致这部分环境变量包含 Surrogate 的原因就是 LC_TYPE 指定的编码不正确。

当环境变量 LC_CTYPELC_ALLLANG 均没有显式定义时,各个操作系统有各自的 LC_CTYPE 的缺省值(比如 C)。这种情况下,通过 sys.getfilesystemencoding() 通常会得到 ascii,环境变量中包含非 ASCII 字符就会得到上面的 Surrogate。

真正的问题

到了这里,问题根源和解决方法基本上已经水落石出了。

前面说手动跑 Worker 可以但是 supervisor 跑 Worker 就不行,这是因为被 supervisor 管理的子进程并不会单独开 Shell 而是继承 supervisord 的 Shell,所以基本的环境变量也有不同。

解决方法便是在 supervisor 配置中修改 environment,加入 LC_CTYPE 或者 LC_ALL 或者 LANG 环境变量的配置。

附,LANGLC_ALLLC_TYPE 什么的之间到底是个啥关系:查找 LC_X 的值的过程是:LC_ALL 有值就用 LC_ALL 的值;否则 LC_X 有值就用 LC_X 的值;否则 LANG 有值就用 LANG 的值;否则就用操作系统的默认值。

以上。

参考文档