阅读视图

发现新文章,点击刷新页面。
🔲 ☆

牙疼的时候别跟我聊阿德勒

前阵子读完了《被讨厌的勇气》。

说实话,读到一半的时候我是服气的。目的论那部分,说愤怒不是原因导致的结果,而是你先有了发火的目的,才制造出愤怒这个工具。我当时想,确实,我上次跟同事急眼,挂了电话气就消了大半。要是愤怒真是被触发的,哪能收得这么利索。

课题分离也挺好。别人怎么看你,是别人的课题。你管不了,也不该管。干净利落。

读完合上书,我觉得自己好像通透了一点。

然后当天晚上我牙疼了。


疼得睡不着那种。

我躺在床上,满脑子就一个念头:疼。不是"别人觉得我疼不疼"的疼,不是"我选择用疼来达到某种目的"的疼。就是牙神经在抗议,纯粹的、跟任何人都没关系的疼。

阿德勒说,一切烦恼皆来自人际关系。

我当时就想,大哥,你来疼一个试试。


我后来认真想了想这句话,越想越觉得它漂亮但不对。

如果这个宇宙只剩我一个人,没有任何人际关系了,我就没烦恼了?饿的时候呢?冷的时候呢?地震来了呢?

这些恐惧是刻在基因里的,不需要有观众。

甚至不用那么极端。就说写代码这件事。有时候一段逻辑死活调不通,我坐在电脑前,烦得想把键盘摔了。这烦躁是因为怕领导骂吗?不全是。更多的是一种"我觉得自己应该能搞定,但就是搞不定"的挫败。

那是我跟问题本身的较劲。跟别人无关。

有点像下棋,对面坐的不是人,是棋盘本身。你输了,你烦,不是因为丢脸,是因为你明明看到了一条路,就是走不通。

把这种烦恼也归到人际关系里,老实说,有点牵强了。


不过目的论那部分,我倒不是完全不认。

它有道理的地方在于,它提醒你,很多情绪确实不是"发生了什么"决定的,而是"你想要什么结果"决定的。

我以前写过一段话,大概意思是,你在饭店被服务员泼了汤,你大发雷霆,目的论会说你是先有了想发火的目的,才捏造出愤怒。因为如果老板这时候打电话来,你立马就能切换成正常人。

这个解释我现在依然觉得有意思。

但"有意思"和"能解释一切"是两回事。


真正让我犹豫的,是课题分离在亲密关系里的适用性。

理论上,课题分离是解决纠纷的神器。你做你的选择,我做我的选择,谁也别替谁操心。

但你试试对你妈说:"你担心我找不到对象,这是你的课题,不是我的。"

看她会不会拿拖鞋抽你。

我是说,在那种不太亲近的关系里,课题分离确实好用。同事觉得你不合群?那是他的课题。路人觉得你穿得丑?那是他的课题。干净,清爽,省心。

可到了真正亲密的关系里——你爸妈、你的伴侣、你最好的朋友——课题分离的那条线,就没那么好画了。

因为亲密关系的本质,好像就是课题的交织。

你关心一个人,就是在主动把自己的情绪交给对方的课题。你妈担心你,不是因为她分不清课题,是因为她愿意把你的事当成自己的事。你要是跟她说"这是我的课题",道理她可能懂,但她会觉得冷。

也许不只是觉得。

就是冷。


我有时候想,阿德勒的理论像一把很锋利的刀。

它能切掉很多不必要的纠缠——那些你本来就不该背的包袱、不该在意的眼光、不该为之愤怒的破事。这些它切得又快又准。

但刀太锋利了,有时候会连不该切的东西也一起切掉。

比如那种笨拙的、越界的、分不清你我的关心。
比如明知道是对方的课题,但还是忍不住多说一句的冲动。

这些东西不够"理性",不够"清醒"。但它们也许恰恰是亲密关系里最真的部分。


我大概不算阿德勒的信徒。

但我也不讨厌他。他说的很多东西在我看来是"八十分的真话"——大部分时候成立,少部分时候失灵。而那失灵的二十分,恰好是生活里最疼的地方。

牙疼的夜晚,算法调不通的下午,还有你妈明知道你不爱听但还是要唠叨的那些话。

这些东西不在任何一个漂亮的理论框架里。

但它们在。

🔲 ☆

为什么 UTF-8 能一统天下:字符编码的生存竞赛

前言

写代码这么多年, 乱码这玩意应该每个人都碰到过. 数据库里存的好好的中文, 取出来一看变成了一堆问号; 或者打开一个文本文件, 满屏的"锟斤拷". 每次碰到这种问题, 改一下编码就好了, 但一直没认真想过: 为什么会有这么多编码? UTF-8 又是凭什么成为事实标准的?

最近翻了翻字符编码的历史, 发现这里面还挺有意思的. 简单来说就是一个"从各自为政到大一统"的故事, 而 UTF-8 能赢, 靠的不是什么高深的技术, 而是一个朴素的设计哲学: 向前兼容.

ASCII: 一切的起点

故事要从 ASCII 说起.

上世纪 60 年代, 美国人搞了一套字符编码标准, 用 7 个 bit 来表示字符, 总共能编码 128 个字符. 包括英文大小写字母、数字、标点符号, 再加上一些控制字符(换行、回车这些).

128 个字符, 对英语来说完全够用了. 而且因为计算机存储以字节(8 bit)为单位, 7 bit 的 ASCII 正好塞进一个字节里, 最高位空着, 设为 0. 简洁, 高效, 没毛病.

但问题来了: 这世界上又不是只有英语.

各自为政的混乱时代

ASCII 只能表示 128 个字符, 连个 ü 都搞不定, 欧洲人第一个不答应. 于是大家开始打那个空闲的最高位的主意——把第 8 位也用上, 这样就有 256 个位置了, 多出来的 128 个位置各取所需.

这就是 ISO-8859 系列的由来. 西欧搞了 ISO-8859-1(又叫 Latin-1), 把法语的 é、德语的 ß 这些字符塞了进去; 东欧搞了 ISO-8859-2; 希腊搞了 ISO-8859-7; 以此类推, 一共搞出了十几个版本.

问题已经开始出现了: 同一个字节值, 在不同的编码下代表不同的字符. 比如 0xC0 这个字节, 在 Latin-1 里是 À, 在 ISO-8859-5 里就变成了一个西里尔字母. 你用 Latin-1 写的文件, 拿到一个装了 ISO-8859-5 的机器上打开, 就是乱码. 不过至少欧洲的字母语言还算是占了便宜, 256 个位置虽然挤了点, 勉强能塞下.

到了中日韩这边, 事情就彻底失控了.

光是常用汉字就有好几千个, 256 个位置怎么可能够? 所以只能用两个字节来表示一个字符. 中国大陆搞了 GB2312, 后来扩展成了 GBK, 再后来又搞了 GB18030; 台湾搞了 Big5; 日本搞了 Shift-JIS 和 EUC-JP; 韩国搞了 EUC-KR. 每个地区各搞一套, 互不兼容.

这时候的编码世界大概是这么个状态:

graph LR A["ASCII 128字符"] --> B["ISO-8859 系列"] A --> C["GB2312 / GBK"] A --> D["Big5"] A --> E["Shift-JIS"] A --> F["EUC-KR"] B --> B1["Latin-1 西欧"] B --> B2["ISO-8859-5 俄语"] B --> B3["ISO-8859-7 希腊语"]

每个方案都只管自己的地盘, 出了自己的地盘就抓瞎. 你要是写一个网页, 里面同时有中文和日文, 用哪个编码? 哪个都不行.

更要命的是, 这些双字节编码和 ASCII 的兼容方式也各不相同. 有的编码方案里, 第一个字节的范围和 ASCII 有重叠, 导致解析的时候分不清一个字节到底是一个独立的 ASCII 字符, 还是某个双字节字符的一部分. Shift-JIS 就有这个毛病, 写过处理日文文本程序的应该深有体会.

这就是 UTF-8 出现之前的世界: 一片混乱.

Unicode: 理想很丰满

有问题就有人想解决. 上世纪 80 年代末, 有人提出了一个大胆的想法: 搞一个统一的字符集, 把全世界所有的文字都收进来, 每个字符给一个唯一的编号. 这就是 Unicode.

Unicode 的思路很简单——给每个字符分配一个唯一的编号, 叫做"码点"(Code Point), 用 U+ 加十六进制数表示. 比如:

  • U+0041 是大写字母 A
  • U+4F60 是汉字
  • U+1F600 是 emoji 😀

目前 Unicode 已经收录了超过 14 万个字符, 覆盖了几乎所有现存的文字系统, 甚至包括一些已经消亡的古文字. 码点的范围从 U+0000U+10FFFF, 理论上可以容纳一百多万个字符.

但 Unicode 本身只是一个"字符集", 它只负责给字符编号, 并不管这些编号在计算机里怎么存储. 编号和存储是两码事. 就好比你给全国每个人分配了一个身份证号, 但身份证号怎么印到卡上、用什么材质、多大字号, 那是另一个问题.

所以, Unicode 需要一种"编码方案"来把码点转换成实际的字节序列. 而这种编码方案, 不止一种.

UTF-32: 简单粗暴

最直接的方式: 每个字符固定用 4 个字节表示. 码点是多少, 直接存进去, 不够的高位补零.

A 的码点是 U+0041, 存成 00 00 00 41. 的码点是 U+4F60, 存成 00 00 4F 60.

优点是实现简单, 定长编码, 随机访问方便——想取第 N 个字符, 直接偏移 N*4 个字节就行.

缺点也很明显: 浪费空间. 一个 ASCII 字符本来 1 个字节就够了, 现在要用 4 个字节, 前面 3 个字节全是零. 对于以英文为主的文本, 文件体积直接膨胀到原来的 4 倍. 这谁受得了?

而且还有个字节序的问题. 00 00 00 41 这 4 个字节, 到底是高位在前(大端)还是低位在前(小端)? 不同的 CPU 架构有不同的偏好, 所以 UTF-32 还分 UTF-32BE 和 UTF-32LE. 为了标明字节序, 文件开头还得加一个 BOM(Byte Order Mark). 越搞越复杂.

UTF-16: 折中方案

UTF-16 做了个折中: 常用字符(BMP, 基本多语言平面, 码点在 U+0000U+FFFF 之间的)用 2 个字节表示, 不常用的(如一些 emoji 和古文字)用 4 个字节.

看起来比 UTF-32 省空间了, 但问题也不少:

  • 对于纯英文文本, 还是比 ASCII 大一倍
  • 变长编码(2 字节或 4 字节), 随机访问的优势没了
  • 同样有字节序问题, 分 UTF-16BE 和 UTF-16LE
  • 最致命的: 和 ASCII 不兼容. 一个 ASCII 字符 A 在 UTF-16 里是 00 41, 中间那个 00 字节, 在 C 语言里就是字符串结束符 \0. 任何处理 C 风格字符串的程序, 看到这个 00 就认为字符串结束了. 直接崩.

Java 和 Windows 早期选择了 UTF-16 作为内部编码, 这在当年 Unicode 字符还没那么多的时候看起来是个合理的选择. 但随着 emoji 和更多字符的加入, UTF-16 "定长 2 字节"的假设被打破了, 留下了不少历史包袱.

UTF-8: 为什么是它

终于轮到主角了.

UTF-8 是 1992 年由 Ken Thompson 和 Rob Pike 在一张餐巾纸上设计出来的(没错, 就是发明 Unix 和 Go 语言的那两位). 它的核心思想是: 用变长的字节序列来编码 Unicode 码点, 而且要和 ASCII 完全兼容.

编码规则如下:

码点范围 字节数 字节1 字节2 字节3 字节4
U+0000U+007F 1 0xxxxxxx
U+0080U+07FF 2 110xxxxx 10xxxxxx
U+0800U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

表里的 x 就是实际码点值的二进制位, 从低位往高位填充.

来实际算一个. 汉字"你"的码点是 U+4F60, 转成二进制是 0100 1111 0110 0000, 一共 15 个有效位. 查上面的表, 15 位落在第三行(3 字节编码, 最多容纳 16 个有效位), 所以编码模板是 1110xxxx 10xxxxxx 10xxxxxx.

把二进制位从右往左填进去:

码点二进制: 0100 1111 0110 0000
拆分:       0100   111101   100000
填充:    1110-0100 10-111101 10-100000
十六进制:    E4       BD       A0

所以"你"的 UTF-8 编码就是 E4 BD A0, 3 个字节. 用 Python 验证一下:

>>> '你'.encode('utf-8')
b'\xe4\xbd\xa0'

没毛病.

为什么这个设计能赢

看完编码规则, 可能觉得也就那样, 无非是个变长编码嘛. 但仔细想想, 这里面藏了好几个精妙的设计:

1. 完全兼容 ASCII

码点 U+0000U+007F 的字符, 编码后还是单字节, 而且字节值和 ASCII 一模一样. 这意味着任何合法的 ASCII 文本, 同时也是合法的 UTF-8 文本, 不需要做任何转换.

这一点太重要了. 互联网上大量的协议、配置文件、源代码都是 ASCII 的, 如果新编码不兼容 ASCII, 推广的阻力会大得多. UTF-16 就是因为不兼容 ASCII, 在很多场景下推不动.

2. 自同步(Self-synchronizing)

看编码规则, 每种字节都有明确的特征:

  • 单字节字符: 最高位是 0
  • 多字节序列的首字节: 以 110111011110 开头
  • 后续字节: 以 10 开头

这意味着你随便从一个 UTF-8 字节流的中间位置开始读, 都能快速判断当前字节是不是一个字符的起始位置. 如果以 10 开头, 就往前找, 最多往前找 3 个字节就能找到首字节.

这个特性在处理损坏的数据、网络传输中截断的数据时非常有用. 一个字节损坏, 最多影响一个字符, 不会像某些编码那样导致后面所有字符全部解析错误, 产生"雪崩效应".

3. 没有字节序问题

UTF-8 的编码单元是字节, 不是多字节整数, 所以不存在大端小端的问题. 不需要 BOM, 不需要检测字节序, 写入的字节序列在任何机器上读出来都一样.

UTF-16 和 UTF-32 就没这个待遇了, 文件开头不加 BOM, 解码器就得猜.

4. 排序保持一致

UTF-8 编码后的字节序列, 按字节排序的结果和按码点排序的结果是一致的. 这意味着你不需要先解码再排序, 直接按字节比较就行. 对于数据库索引、文件系统排序这些场景, 这个特性省了不少事.

5. 空间效率合理

对于英文文本, UTF-8 和 ASCII 一样, 1 字节一个字符, 零浪费. 对于中文文本, 3 字节一个字符, 虽然比 GBK 的 2 字节多了 50%, 但考虑到中文文本里通常也夹杂着不少 ASCII 字符(标点、数字、英文术语), 实际差距没那么大.

而且在互联网场景下, 英文内容占了大头, UTF-8 对英文的零开销使得它在全球范围内的平均空间效率非常优秀.

编码检测: 一个有意思的副产品

顺便说一个 UTF-8 的附加好处: 它比较容易被正确检测.

因为 UTF-8 的字节模式有严格的约束(首字节和后续字节的格式是固定的), 一段随机的二进制数据碰巧是合法 UTF-8 的概率其实挺低的. 所以当你拿到一个不知道编码的文件时, 尝试按 UTF-8 解码, 如果能成功解码且没有非法序列, 那它大概率就是 UTF-8.

相比之下, GBK 和 Latin-1 的字节范围大量重叠, 光看字节值很难分辨. 这也是为什么浏览器早期经常猜错编码, 导致乱码的原因之一.

那些"锟斤拷"是怎么来的

提到乱码, 顺便解释一下"锟斤拷"这个经典乱码.

Unicode 里有一个特殊字符叫 U+FFFD (REPLACEMENT CHARACTER, 就是那个 ), 专门用来表示"这里有个字符, 但我解码失败了". 它的 UTF-8 编码是 EF BF BD.

当一段 GBK 编码的文本被错误地当作 UTF-8 解码时, 很多字节序列是非法的 UTF-8, 解码器就会用 U+FFFD 来替代. 这样就产生了一堆 EF BF BD 字节.

然后, 如果你再把这堆字节用 GBK 去解码——两个字节一组, EF BF 对应"锟", BD EF 对应"斤", BF BD 对应"拷". 于是经典的"锟斤拷"就诞生了.

说白了就是编码和解码用的方案对不上, 来回折腾了两次, 就变成了这副鬼样子.

现状

现在回头看, UTF-8 的胜出几乎是必然的. 它做对了最关键的一件事: 不破坏已有的生态.

兼容 ASCII 意味着大量现有的工具、库、协议不需要修改就能处理 UTF-8 文本. C 语言的 strlenstrcpy 这些函数, 虽然不能正确计算 UTF-8 字符串的字符数, 但至少不会因为遇到 \0 而提前截断(和 UTF-16 形成鲜明对比). 文件路径、环境变量、命令行参数, 这些系统级的字符串处理, 切换到 UTF-8 的成本最低.

据 W3Techs 的统计, 截至目前互联网上超过 98% 的网页使用 UTF-8 编码. Linux 系统的默认 locale 是 UTF-8, Go 语言的源码和字符串默认是 UTF-8, Rust 的 String 类型内部就是 UTF-8. 新的协议和标准也基本都默认 UTF-8.

当然 UTF-8 也不是完美的. 对于 CJK 文字(中日韩)来说, 每个字符 3 字节确实不如 GBK 的 2 字节紧凑. 但在一个全球化的网络环境下, "能正确处理所有语言的文字且与 ASCII 兼容"这张牌, 比"某种语言省一个字节"重要得多.

编码的故事到这基本就讲完了. 从 ASCII 的 128 个字符, 到各国自扫门前雪的混战, 再到 Unicode 一统编号、UTF-8 一统编码, 花了大概三十年. 本质上就是一个兼容性打败一切的故事——在技术标准的世界里, 能和已有生态和平共处的方案, 往往比"理论上更优"的方案活得更久.

以上, 简单聊了聊字符编码这些事, 溜了溜了.

🔲 ☆

禅与摩托车维修艺术:一场关于「良质」的公路旅行

前言

说来惭愧, 这本书在我的书架上吃灰了大概有一年. 当时买它完全是因为书名, 禅? 摩托车? 维修? 这三个词放在一起, 怎么看都不像是能组成一本书的. 就好比你跟我说「量子力学与红烧肉烹饪指南」, 我第一反应肯定是——这是个什么东西?

但翻开之后, 才发现自己被书名骗了. 这哪里是什么维修手册, 分明是一个中年男人骑着摩托车横穿美国, 一路上跟你聊哲学的故事. 而且聊的还不是那种让人昏昏欲睡的学院派哲学, 是那种会让你在深夜突然坐起来, 觉得「好像有点道理」的哲学.

好, 既然读完了, 那就来聊聊这本书到底在说什么.

一趟公路旅行, 两种世界观

故事的框架其实很简单: 一个父亲带着儿子, 和另一对夫妻约翰夫妇, 一起骑摩托车从明尼苏达到旧金山. 沿途的风景描写是有的, 但更多的是主人公脑子里翻涌的思考, 作者把这些思考叫做「肖陶扩」, 也就是一种旅途中的哲学漫谈.

最有意思的切入点, 是两种截然不同的态度.

主人公自己动手维修摩托车, 遇到问题会拆开来分析、诊断、修理. 而他的朋友约翰呢, 骑的是一辆更贵的宝马摩托, 但约翰对机械这东西完全不感兴趣, 摩托车坏了就送修理铺, 甚至连看都不想看一眼. 这并不是因为约翰懒, 而是他从心底里排斥「技术」这个东西.

读到这里的时候, 我突然就想到了身边的很多人. 有些同事对自己用的工具、框架, 总是抱着一种好奇心, 想知道它底层到底是怎么运转的. 而有些人呢, 工具能跑就行, 别跟我说原理, 我不想知道. 这两种人我都见过, 说不上谁对谁错, 但确实是两种完全不同的世界观.

作者把这两种态度归纳为「古典认知」和「浪漫认知」:

  • 古典认知: 关注事物的内在形式, 喜欢分析结构和原理. 看到摩托车, 想的是发动机怎么运转, 点火时序是什么.
  • 浪漫认知: 关注事物的外在表象, 重视直觉和感受. 看到摩托车, 想的是风驰电掣的自由感.

乍一看, 这不就是理科生和文科生的区别嘛. 但作者说, 这两种认知方式的割裂, 才是现代人精神困境的根源. 科技让生活变好了, 但很多人对科技又有一种本能的疏离感, 觉得它冰冷、没有人情味. 就像约翰, 享受着摩托车带来的便利, 却不愿意和它发生任何「深层联系」.

良质: 一个说不清道不明的东西

全书最核心的概念, 就是「良质」(Quality). 说实话, 读第一遍的时候, 我觉得作者在故弄玄虚. 什么叫良质? 你倒是给个定义啊.

但作者偏偏不给定义. 他说, 良质是不可定义的. 一旦你定义了它, 它就不是良质了. 这话听着是不是很像老子说的「道可道, 非常道」? 没错, 这也是书名里「禅」字的由来.

作者用了一个很巧妙的例子来说明. 他在大学教写作课的时候, 做过一个实验: 让学生评价两篇文章哪篇写得好. 结果大部分学生的判断是一致的. 也就是说, 大家都能「感受」到哪篇文章的质量更好, 但如果你追问「好在哪里」, 每个人给出的理由又不一样.

良质就是这么一个东西——你知道它在那儿, 你能感受到它, 但你说不清楚它到底是什么.

这让我想到了写代码. 你有没有过这样的体验: 看到一段代码, 说不出具体哪里不对, 但就是觉得「这代码写得不行」. 又或者看到一段代码, 逻辑清晰, 命名规范, 结构优雅, 你会由衷地觉得「写得真好」. 这个「好」和「不行」, 是不是也是一种良质的感知?

如果按照作者的思路来画个图的话, 大概是这样的:

graph TD A["良质 Quality"] --> B["古典认知"] A --> C["浪漫认知"] B --> D["理性分析"] B --> E["逻辑推理"] C --> F["直觉感受"] C --> G["审美体验"] D --> H["技术实践"] F --> H E --> H G --> H H --> I["真正的好作品"]

良质不属于古典, 也不属于浪漫, 它在两者之上. 或者说, 良质是连接理性和感性的那座桥. 一个真正好的作品, 无论是一篇文章、一段代码, 还是一辆修好的摩托车, 都是理性和感性共同作用的结果.

关于「卡住」这件事

书中有一大段在讲「卡住」这件事, 我觉得特别有共鸣.

作者说, 修摩托车的时候, 经常会遇到卡住的情况. 比如一颗螺丝拧不下来, 你越使劲越拧不动, 最后把螺丝拧滑丝了. 这时候, 大部分人会暴跳如雷, 然后把扳手往地上一扔, 骂骂咧咧地走了.

但作者说, 卡住其实是好事.

为什么? 因为卡住意味着你现有的认知模型不够用了, 你需要跳出来, 用新的视角看问题. 那颗拧不下来的螺丝, 也许不是你力气不够, 而是你用错了工具, 或者根本就不应该从这个方向拧.

这段话让我一下子就想到了 debug 的经历. 有时候一个 bug 查了一下午, 各种 log 加了一堆, 就是找不到原因. 越查越烦躁, 越烦躁越找不到. 然后你站起来去接杯水, 或者上个厕所, 回来一看——哦, 原来是这里写错了.

作者的意思是, 当你被卡住的时候, 不要急着往前冲, 先停下来, 泡杯茶, 想想是不是方向错了. 卡住不是终点, 而是一个信号, 告诉你该换条路走了.

他还列了几种导致「卡住」的陷阱, 我觉得每一种都很精准:

价值陷阱: 你对结果的期望影响了你的判断. 比如你已经花了三个小时修一个 bug, 沉没成本让你不愿意承认当前的方案是错的.

真理陷阱: 你过于依赖逻辑和推理, 忽略了直觉. 所有证据都指向 A, 但你的直觉告诉你是 B, 这时候要不要相信直觉?

肌肉陷阱: 身体状态影响思维. 困了累了饿了, 都会让你做出糟糕的判断. 别硬撑, 先休息.

看, 修摩托车和写代码, 其实是一回事.

「用心」这件事

全书读下来, 如果非要我用一个词来概括, 我会选「用心」.

作者反复强调的一件事就是: 不管你做什么, 修摩托车也好, 写文章也好, 做饭也好, 写代码也好, 最重要的是你有没有「用心」在做.

他举了一个修理工的例子. 你把摩托车送到修理铺, 修理工一边听着收音机一边干活, 手上在拧螺丝, 脑子里想的是今晚吃什么. 修好了, 你骑回去, 过两天又出问题了. 为什么? 因为他在修车的时候, 心不在焉, 他和这辆摩托车之间没有建立起任何联系.

而如果一个人用心去修, 他会观察、会思考、会和这个机器「对话」. 他知道哪个零件有点松了, 哪个地方发出的声音不太对. 这种用心, 就是良质的来源.

这让我想到了很多东西. 为什么有些人写的代码, 看起来就是比别人的舒服? 不是因为他用了什么高级的设计模式, 而是你能感觉到他在写每一行代码的时候都是「在的」, 都经过了思考. 变量的命名、函数的拆分、异常的处理, 处处透着一股用心.

反过来, 那些让你看了头疼的代码, 往往不是因为写的人技术差, 而是因为他在写的时候「不在」——心思不在代码上, 只想着赶紧写完交差.

斐德洛的悲剧

书中还穿插了一条暗线, 就是主人公过去的另一个人格——斐德洛. 斐德洛是一个极其聪明的人, 他疯狂地追问「良质到底是什么」, 追问到了哲学的尽头, 最终精神崩溃, 被送去做了电击治疗.

这条线读起来有点压抑, 但也很真实. 一个人如果把某个问题想得太深, 深到了现有知识体系无法容纳的程度, 确实是有可能被这个问题吞噬的.

好在主人公最后和斐德洛达成了某种和解. 他不再试图给良质下定义, 而是学会了在生活中去感受它、实践它. 就像书的结尾, 他和儿子骑着摩托车, 沿着海岸线前进, 一切都很平静.

读到这里, 我突然明白了为什么这本书叫「禅与摩托车维修艺术」. 禅不是坐在那里冥想, 禅是你全神贯注做一件事情时的那种状态. 修摩托车可以是禅, 写代码可以是禅, 做饭可以是禅. 关键不在于你做的是什么, 而在于你做的时候, 心在不在.

写在最后

这本书我前前后后翻了大概两周, 不算长, 但确实需要时间消化. 有些段落读起来会觉得作者在绕, 但如果你耐下心来, 会发现他绕了一大圈, 最后都会回到那个核心的点上.

如果让我给这本书挑个毛病, 那就是——它确实不好读. 哲学的部分有时候过于抽象, 如果没有一定的哲学基础, 读起来可能会比较吃力. 但好在作者一直在用修摩托车这个具象的例子来做类比, 所以即使看不懂哲学的部分, 也能 get 到他想表达的意思.

最后, 用书中一句让我印象最深的话来结尾吧:

佛陀或耶稣坐在电脑和变速器的齿轮旁边, 会像坐在山顶和莲花座上一样自在.

这句话放到我们的语境里就是: 不要觉得写代码是一件「low」的事情, 也不要觉得技术是冰冷的. 当你用心去做的时候, 在键盘上敲出的每一行代码, 和山顶上的禅修, 没什么区别.

好了, 就聊到这. 下次修 bug 的时候, 记得泡杯茶先.

🔲 ☆

谁还需要 Kafka 啊?我用两个 UNIX 信号手捏了一个消息队列!

天下苦复杂中间件久矣!

看看我们现在的项目,动辄就要引入 Kafka、RabbitMQ、RocketMQ……光是部署这些环境、调优 JVM、配置集群,就能让一个好好的周末泡汤。你有没有在某个深夜抓狂时想过:“老夫就是想在两个进程之间传个字符串,难道就没有那种最极简、最硬核、最不需要第三方依赖的办法吗?”

如果你没这么想过,恭喜你,你是一个情绪稳定的正常程序员。

但我不仅想了,我还真干了!今天,我要带大家玩一把“文艺复兴”,彻底抛弃所有高级网络协议和中间件,仅用操作系统最底层的两个 UNIX 信号,从零手撸一个消息队列! 不管你是想轻松学点底层 IPC(进程间通信)知识,还是想复习一下快忘光的二进制位运算,亦或只是单纯想看我怎么一本正经地折腾没用的东西,这篇文章都绝对合你的胃口。

准备好了吗?我们要开始“作妖”了。


处在 IPC 鄙视链底端的“信号”

提到进程间通信(IPC, Inter-Process Communication),大家脑子里蹦出来的肯定是:

  1. Socket(套接字):老大哥,网络通信的绝对霸主。
  2. Pipe(管道):也就是我们常用的 | 符,比如 echo "hello" | grep "h",简单粗暴。
  3. Shared Memory(共享内存):性能怪兽,但处理同步问题能让人掉光头发。

相比之下,Signal(信号) 简直就是处于 IPC 鄙视链的绝对底端。为啥?因为它本来就不是设计用来传数据的!

按照 UNIX 系统的设定,信号就像是操作系统给进程发的“短消息通知”,通常只代表一个动作:

  • SIGKILL:阎王让你三更死,谁敢留人到五更。(强制结束,无法捕获)
  • SIGTERM:温柔一刀,给你个机会料理后事。(优雅停机)
  • SIGINT:你在终端疯狂按 Ctrl+C 产生的就是这玩意。

信号本身不携带任何数据载荷。这就好比我给你打了个响指,你只知道我打了响指,但没法通过响指本身知道我中午想吃黄焖鸡还是兰州拉面。

但是!(重点来了)

UNIX 系统非常贴心地留了两个“用户自定义信号”:SIGUSR1SIGUSR2。这就给了我们搞事情的绝佳机会。

脑洞大开:把信号变成摩斯密码

既然信号不能带数据,那我们怎么传消息?
很简单,回到计算机最本质的世界:0 和 1

只要是消息,不管多长多复杂,在内存里最终都是由 0 和 1 组成的二进制串。我们手头刚好有两个自定义信号,那不如这样约定:

  • 收到 SIGUSR1,就代表我给你发了一个 0
  • 收到 SIGUSR2,就代表我给你发了一个 1

这就是我们的“摩斯密码”!

以小写字母 h 为例:
它在 ASCII 码表里的十进制值是 104,转换成二进制就是 01101000
如果我们想把 h 发送给另一个进程,只需要按顺序给那个进程发送 8 次信号:
SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR1 (0) -> SIGUSR2 (1) -> SIGUSR2 (1) -> SIGUSR1 (0)
(注:这里为了方便,我们假设从最低位 LSB 开始发送)

只要发送端负责把字母“拆”成位,接收端负责把位“拼”回字母,这不就成了吗?!


核心魔法:位运算的拆与拼

要实现这个脑洞,我们需要用一点点位运算(Bitwise Operations)。别一听位运算就跑,其实特别简单。这次我们用 Go 语言 来演示,因为 Go 处理并发和系统级 API 简直顺滑得不像话。

1. 发送端:怎么把一个字节“拆”成 8 个位?

假设我们有一个字节 byte,要想知道它第 i 位是 0 还是 1,我们可以用这个黄金公式:
bit = (byte >> i) & 1

原理剖析:

  • >> 是右移操作符。它能把二进制串整体向右推 i 个位置,原来在第 i 位的数据,就被推到了最右边(最低位)。
  • & 1 是按位“与”操作。因为 1 的二进制是 00000001,任何数和它做“与”操作,都会把前面的高位全部清零,只保留最右边那一位!

h (104, 01101000) 开刀,我们从第 0 位(最右侧)一直剥到第 7 位(最左侧):

  • 第 0 位:(104 >> 0) & 1 -> 104 与 1 -> 结果是 0
  • 第 1 位:(104 >> 1) & 1 -> 52 与 1 -> 结果是 0
  • 第 2 位:(104 >> 2) & 1 -> 26 与 1 -> 结果是 0
  • 第 3 位:(104 >> 3) & 1 -> 13 与 1 -> 结果是 1
  • 第 4 位:(104 >> 4) & 1 -> 6 与 1 -> 结果是 0
  • 第 5 位:(104 >> 5) & 1 -> 3 与 1 -> 结果是 1
  • 第 6 位:(104 >> 6) & 1 -> 1 与 1 -> 结果是 1
  • 第 7 位:(104 >> 7) & 1 -> 0 与 1 -> 结果是 0

完美!我们得到了序列 0, 0, 0, 1, 0, 1, 1, 0,接下来只要把它们换成 SIGUSR1SIGUSR2 发出去就行了。

2. 接收端:怎么把 8 个位“拼”回一个字节?

接收端的工作正好相反,它要用到的武器是左移操作(<<)。
一开始,我们搞一个空字节,值为 0。然后监听信号。

  • 如果来了个 0,不管它。
  • 如果来了个 1,我们就把它往左移 i 个位置,然后“加”到我们的空字节上。

公式:accumulator += (bit << position)

position 走到 8 的时候,说明凑齐了一桌麻将(一个字节),直接把这个字节转成字符打印出来,然后将 positionaccumulator 清零,准备迎接下一个字节。


废话少说,Show Me The Code!

先写接收端 (Consumer)

这哥们的主要任务就是老老实实呆在后台,监听我们要给它发的信号。

// consumer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;os/signal&quot;
    &quot;syscall&quot;
)

func main() {
    // 打印 PID,不然发送端不知道要把信号打给谁
    fmt.Printf(&quot;😎 消费者已启动!我的进程 PID 是: %d\n&quot;, os.Getpid())
    fmt.Println(&quot;🎧 正在竖起耳朵等待 UNIX 信号...&quot;)

    // 注册通道,专门截获 SIGUSR1 和 SIGUSR2
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGUSR1, syscall.SIGUSR2)

    var accumulator byte = 0
    var position int = 0
    var buffer []byte // 用来存拼好的字符

    for sig := range sigCh {
        var bit byte = 0
        if sig == syscall.SIGUSR2 {
            bit = 1 // SIGUSR2 就是 1
        }

        // 拼图游戏开始,把 bit 推到正确的位置上并累加
        accumulator += (bit &lt;&lt; position)
        position++

        // 收集满 8 个龙珠,召唤一个 Byte
        if position == 8 {
            if accumulator == 0 {
                // 我们约定,收到一个完全是 0 的字节(NULL),代表一句话说完了
                fmt.Printf(&quot;\n✨ 收到完整消息: %s\n&quot;, string(buffer))
                buffer = []byte{} // 清空,准备听下一句
            } else {
                // 没说完就先存进 buffer
                buffer = append(buffer, accumulator)
                fmt.Printf(&quot;%c&quot;, accumulator) // 实时打印看看效果
            }

            // 重置状态
            accumulator = 0
            position = 0
        }
    }
}

再写发送端 (Producer)

发送端就是个无情的发报机,把我们的命令行参数拆碎了发射出去。

// producer.go
package main

import (
    &quot;fmt&quot;
    &quot;os&quot;
    &quot;strconv&quot;
    &quot;syscall&quot;
    &quot;time&quot;
)

func main() {
    if len(os.Args) &lt; 3 {
        fmt.Println(&quot;❌ 姿势不对!正确用法: go run producer.go &lt;PID&gt; &lt;你想发送的骚话&gt;&quot;)
        return
    }

    targetPid, _ := strconv.Atoi(os.Args[1])
    message := os.Args[2]

    fmt.Printf(&quot;🚀 准备向 PID %d 发射消息: [%s]\n&quot;, targetPid, message)

    for i := 0; i &lt; len(message); i++ {
        b := message[i]

        // 庖丁解牛:拆解 8 个位
        for j := 0; j &lt; 8; j++ {
            bit := (b &gt;&gt; j) &amp; 1

            sig := syscall.SIGUSR1
            if bit == 1 {
                sig = syscall.SIGUSR2
            }

            // 发送信号!咻!
            syscall.Kill(targetPid, sig)

            // ⚠ 极其关键的一步:休眠!
            // 如果不睡一会儿,操作系统的内核可能会把密集发送的相同信号合并成一个
            // 那样你的数据就全丢了。这就是底层 IPC 的残酷。
            time.Sleep(2 * time.Millisecond) 
        }
    }

    // 消息发完了,最后发 8 个 0(NULL),告诉接收方“我说完了”
    for j := 0; j &lt; 8; j++ {
        syscall.Kill(targetPid, syscall.SIGUSR1)
        time.Sleep(2 * time.Millisecond)
    }

    fmt.Println(&quot;✅ 消息发送完毕,深藏功与名。&quot;)
}

见证奇迹的时刻

打开两个终端窗口。
在窗口 A 运行:

$ go run consumer.go
😎 消费者已启动!我的进程 PID 是: 8848
🎧 正在竖起耳朵等待 UNIX 信号...

在窗口 B 运行:

$ go run producer.go 8848 &quot;Hello, UNIX!&quot;
🚀 准备向 PID 8848 发射消息: [Hello, UNIX!]
✅ 消息发送完毕,深藏功与名。

此时,你会在窗口 A 看到字符一个一个地蹦出来:

H e l l o ,   U N I X !
✨ 收到完整消息: Hello, UNIX!

是不是有种黑客帝国里字符雨的快感?!


玩得再大点:三层架构的 Broker

既然搞了,干脆贯彻到底,弄个正儿八经的 Pub/Sub(发布/订阅)架构!

我们可以写一个中转站(Broker)进程。

graph TD P1{Producer1} --> B{Broker} P2{Producer2} --> B{Broker} B --> C1{Consumer1} B --> C2{Consumer2}

Broker 到底是个啥角色?
其实,Broker 本质上就是一个“缝合怪”:它对外兼具了 Consumer 和 Producer 的功能。

  1. 作为接收方:它监听系统发来的 SIGUSR1SIGUSR2,按照我们上面的逻辑,把位拼成完整的字符串消息。
  2. 缓冲与路由:拼好一条消息后,它不打印,而是塞进自己内部的一个内存队列(比如 Go 的 Channel)。
  3. 作为发送方:它后台跑一个死循环,一旦发现队列里有消息,就去查找已注册的下游 Consumer 进程的 PID,然后把消息重新拆成 0 和 1 的信号,像机关枪一样发送过去。

这样一来,Producer 甚至不需要知道 Consumer 的 PID,只需要把信号发给 Broker 就行了,彻底实现了系统的解耦!(听上去是不是非常像大厂里的微服务架构介绍?)


灵魂拷问:这玩意能上生产环境吗?

如果你觉得这套架构很帅,打算明天去公司把它整合到你们的核心支付业务系统里去……那我劝你最好先准备好离职报告。

永远,绝对,不要在生产环境中这么做!

为什么?因为用 UNIX 信号当消息队列,缺陷多到令人发指:

  1. 慢如老牛:每发送一个“位”的数据,都会触发一次由系统空间到用户空间的上下文切换。为了防止信号丢失,我们在每次发送后都加了 Sleep。发一个字符要 16 毫秒,发一段 100 字的消息要将近 2 秒。这在现代软件里简直是世纪末的灾难。
  2. 极不可靠:信号是没有持久化机制的,发丢了就是丢了,没法重试,更没有“消息确认(ACK)”。
  3. 不支持并发:如果两个 Producer 同时向一个 Broker 狂发信号,0 和 1 就会严重交错污染。Broker 解析出来的绝对是一串毫无逻辑的外星文乱码。

那我们今天费这么大劲折腾这玩意,图个啥?

图的是看透本质的爽感

在这个大家都在卷各种上层框架、中间件 API 的时代,我们很容易迷失在各种高大上的术语里。但当我们扒掉 Kafka、RabbitMQ 华丽的封装外衣,下沉到操作系统的深水区时,你会发现,所有的“消息”、“通信”、“流转”,归根结底,也不过就是底层内存和 CPU 的 0 与 1 的游戏。

搞明白位运算,搞明白进程间的最原始交互方式,能在你以后排查那些极其诡异的架构 Bug 时,提供意想不到的直觉与灵感。

退一万步讲,下次面试官再问你:“除了常用中间件,你还了解哪些 IPC 方式?”
你就可以微微一笑,靠在椅背上淡淡地说:“我曾经仅用两个 UNIX 信号就手写了一个消息队列,虽然毫无卵用,但是非常酷。”

Happy Hacking!

🔲 ☆

高德地图红绿灯倒计时之实现原理

概述

相信大家在开车导航时都注意到了,高德地图(以及其他导航软件)现在能在路口精准地显示红绿灯的倒计时,甚至还能告诉你“需要等待 2 轮红灯”。

很多人第一反应是:“高德是不是接入了交警的红绿灯后台数据?”

答案是:极少部分是接入的,绝大部分是“算”出来的。

如果全靠接入,考虑到全国各地红绿灯系统的封闭性、多样性和数据安全,这几乎是不可能完成的任务。实际上,高德是利用海量的用户轨迹数据,通过算法反向推演出红绿灯的周期。这其中的核心技术,参考了其公开的专利 CN114463969A(红绿灯周期时长的挖掘方法)。

今天我们就来硬核拆解一下,这个“读心术”是如何实现的。

核心原理

通俗的讲,红绿灯的本质是一个周期性函数。只要我们观察到足够多的样本,就能拟合出这个函数。

想象一下,你站在路口,虽然看不见红绿灯,但你发现:

  1. 每隔 60 秒,就有一大波车停下来(红灯)。
  2. 每隔 60 秒,又有一大波车同时起步(绿灯)。

高德做的就是这个“观察者”。它利用海量的用户手机 GPS 数据,分析车辆在路口的 “停车-起步” 行为,进而推算出红绿灯的 周期时长红绿相位

这个过程主要分为三个核心步骤:

  1. 样本筛选:找到经过该路口的车辆轨迹。
  2. 起停波识别:识别车辆何时停车,何时起步。
  3. 周期挖掘:通过起步时间的规律,算出红绿灯周期。

参考专利地址CN114463969A – Method and apparatus for mining traffic signal light cycle

实现细节

1. 数据清洗与事件提取

并不是所有经过路口的轨迹都有用。我们需要筛选出“受红绿灯影响”的轨迹。

  • 有效样本:在路口前速度降为 0,且停留超过一定时间,然后加速通过的车辆。
  • 无效样本:直接绿灯通过的车辆(没有停车特征,无法判断红灯起始点),或者右转车辆(通常不受红绿灯限制)。

对于每一辆有效车辆 i,我们可以提取出一个关键事件:起步时间 t_start
这个 t_start 近似等于绿灯亮起的时间(当然有延迟,后面会说怎么校准)。

2. 聚类形成“起步波”

单个车辆的数据可能存在误差(比如司机走神了,绿灯亮了 3 秒才走)。但如果有 10 辆车在 10:00:05 ~ 10:00:10 之间起步,我们就可以认为这是一个 “绿灯起步波”

我们需要将时间轴上离散的起步点,聚合为一个个簇(Cluster)。

3. 周期计算 (核心数学逻辑)

假设红绿灯周期是 T。那么理想情况下,所有“起步波”的时间差应该是 T 的整数倍。
例如:

  • 第一波起步:10:00:00
  • 第二波起步:10:02:00 (间隔 120s)
  • 第三波起步:10:03:00 (间隔 60s)

可以推断,周期 T 很有可能是 60s(因为 120 和 60 都是 60 的倍数)。

算法会尝试搜索一个最佳的 T,使得所有观测到的起步时间 t 满足以下公式:

t ≈ t_base + k * T + error

其中:

  • t_base 是基准时间
  • k 是整数轮次
  • error 是误差

4. 误差修正(排队论)

这是最难的一点。车辆起步时间 != 绿灯亮起时间
如果一辆车排在第 10 位,它起步的时间可能比绿灯亮起晚 20 秒。

高德的算法会考虑 排队位置。通过 GPS 坐标,可以算出车辆距离停车线的距离。

修正后的绿灯时间 = 实际起步时间 - (排队距离 / 饱和流率)

Golang 代码示例

下面用一段 Go 代码来模拟这个“周期挖掘”的核心逻辑。为了简化,我们假设已经提取到了车辆的起步时间列表。(这个代码只是表达思路使用, 与实际无关)

package main

import (
    "fmt"
    "math"
    "sort"
)

// TrafficLightMiner 模拟红绿灯挖掘器
type TrafficLightMiner struct {
    StartTimes []int64 // 收集到的车辆起步时间戳(秒)
}

// AddTrajectory 添加一条轨迹的起步时间
func (m *TrafficLightMiner) AddTrajectory(startTime int64) {
    m.StartTimes = append(m.StartTimes, startTime)
}

// CalculateCycle 挖掘红绿灯周期
// 原理:寻找一个周期 T,使得绝大多数的时间间隔都是 T 的整数倍
func (m *TrafficLightMiner) CalculateCycle() int {
    if len(m.StartTimes) < 2 {
        return 0
    }

    // 1. 先对时间进行排序
    sort.Slice(m.StartTimes, func(i, j int) bool {
        return m.StartTimes[i] < m.StartTimes[j]
    })

    // 2. 计算相邻起步波的时间差 (Diff)
    // 在实际场景中,这里需要先做聚类,把同一轮红绿灯的车归为一组,取中位数作为该轮的起步时间
    // 这里为了简化,假设输入已经是经过聚类处理后的每轮首车起步时间
    var diffs []int64
    for i := 1; i < len(m.StartTimes); i++ {
        diff := m.StartTimes[i] - m.StartTimes[i-1]
        diffs = append(diffs, diff)
    }

    // 3. 寻找众数或者最大公约数思想的拟合
    // 常见的红绿灯周期通常在 30s - 180s 之间
    // 我们尝试在这个范围内搜索得分最高的周期
    bestCycle := 0
    maxScore := 0.0

    for t := 30; t <= 180; t++ {
        score := m.evaluateCycle(t, diffs)
        if score > maxScore {
            maxScore = score
            bestCycle = t
        }
    }

    return bestCycle
}

// evaluateCycle 评分函数:计算某个周期 T 对数据的解释程度
func (m *TrafficLightMiner) evaluateCycle(T int, diffs []int64) float64 {
    hits := 0.0
    tolerance := 3.0 // 容忍误差 3秒

    for _, diff := range diffs {
        // 计算 diff 是否是 T 的倍数
        // 例如 diff = 122, T = 60, remainder = 2 (在误差允许范围内)
        remainder := math.Mod(float64(diff), float64(T))

        // 处理余数接近 T 的情况 (例如余数 59 相当于 -1)
        if remainder > float64(T)/2 {
            remainder = float64(T) - remainder
        }

        if remainder <= tolerance {
            hits++
        }
    }

    // 返回命中率
    return hits / float64(len(diffs))
}

func main() {
    miner := TrafficLightMiner{}

    // 模拟数据:假设红绿灯周期是 60秒
    // 车辆分别在以下时间点起步 (包含一些随机误差和跳过的轮次)
    // 第1轮: 100s
    // 第2轮: 161s (误差+1s)
    // 第3轮: 没有车通过 (跳过)
    // 第4轮: 280s (100 + 60*3 = 280)
    // 第5轮: 342s (100 + 60*4 + 误差2s)

    simulatedData := []int64{100, 161, 280, 342, 400, 521}

    for _, t := range simulatedData {
        miner.AddTrajectory(t)
    }

    cycle := miner.CalculateCycle()

    fmt.Println("分析样本起步时间:", simulatedData)
    fmt.Printf("计算出的红绿灯周期为: %d 秒\n", cycle)

    // 实际应用中,算出周期后,还需要算出“红灯开始时间”和“偏移量”才能做倒计时
}

优缺点分析

优点:

  1. 覆盖广:不需要依赖政府设备,只要有路口有车在跑,就能算出来。
  2. 成本低:纯软件实现,不需要铺设硬件传感器。
  3. 实时性:如果路口临时改为交警手控(周期错乱),算法检测到数据方差变大,会自动降级不显示,避免误报。

缺点:

  1. 依赖车流:如果是半夜或者偏僻路口,没有车经过,就没有数据样本,也就无法计算。
  2. 受排队影响:如果路口常年严重拥堵,车辆一次绿灯过不去(溢出),起步时间就不代表绿灯开始时间,会导致计算偏差。
  3. 右转干扰:虽然有筛选,但部分路口允许红灯右转,如果清洗不干净,会干扰算法判断。

简单总结,你在高德地图上看到的每一个倒计时数字,背后都是无数辆车的轨迹汇聚而成的“群体智慧”。这就是大数据的魅力。

🔲 ⭐

Golang并发之死锁检测

概述

来看一段简单的测试代码时

package main

import "fmt"

func main() {
    // 创建一个带缓冲的 channel,但没有放入任何数据
    c := make(chan bool, 1)

    // 直接尝试读取,导致死锁
    fmt.Println(<-c)
}

运行程序,控制台立马给了一记响亮的耳光:
fatal error: all goroutines are asleep - deadlock!

看到这个报错,第一反应是:“哇,Go 语言真贴心,竟然能自动检测死锁!”。这让我产生了一种错觉:只要我的代码发生了死锁,Runtime(运行时)一定会第一时间告诉我。

但事实真的如此吗?Golang 自带的死锁检测真的是“银弹”吗?

答案是否定的。Go 的死锁检测机制其实非常“原始”且“有限”。今天我们就来扒一扒它的底裤,看看它到底检测了什么,又漏掉了什么。

并不是银弹:Runtime 无法检测的死锁

Go Runtime 的检测逻辑其实非常简单粗暴,它只关注全局死锁。也就是说,只有当整个程序所有的 Goroutine 都睡着了,它才会报警。

只要你的程序里还有任何一个 Goroutine 在干活(比如正在空转、正在等待网络请求、或者只是单纯的死循环),Runtime 就会认为程序还在正常运行,哪怕你的核心业务逻辑已经死锁卡死了。

下面给几个经典的“漏网之鱼” Demo。

场景一:有一个“旁观者”在运行

这是最常见的场景。假设你的业务逻辑 G1 和 G2 互相卡死(AB-BA 死锁),但只要有一个 G3(比如心跳上报、日志监控)还在跑,Runtime 就不会报错。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mu1, mu2 sync.Mutex

    // G1: 占有锁1,想要锁2
    go func() {
        mu1.Lock()
        defer mu1.Unlock()
        time.Sleep(100 * time.Millisecond) // 让子弹飞一会儿
        mu2.Lock() // <--- 卡死在这里
        mu2.Unlock()
    }()

    // G2: 占有锁2,想要锁1
    go func() {
        mu2.Lock()
        defer mu2.Unlock()
        time.Sleep(100 * time.Millisecond)
        mu1.Lock() // <--- 卡死在这里
        mu1.Unlock()
    }()

    // G3: 一个无辜的旁观者
    // 只要这个协程还在运行,Runtime 就认为系统是健康的
    go func() {
        for {
            time.Sleep(time.Second)
            fmt.Println("系统监控: 我还在跑,Runtime 别慌...")
        }
    }()

    select {} // 主协程阻塞
}

结果:控制台会无限打印“系统监控…”,而 G1 和 G2 早就已经死锁了,由于没有触发 fatal error,这种故障在生产环境中极难被第一时间发现。

场景二:HTTP Server 掩盖死锁

在 Web 开发中,http.ListenAndServe 会启动一个主循环来监听端口。这个监听动作本身就代表“程序正在运行”。因此,后台发生的任何死锁,Runtime 都不会报错。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    ch := make(chan int) // 无缓冲 channel

    // 生产者:写入数据,因为没有消费者,这里会永久阻塞
    go func() {
        fmt.Println("准备写入数据...")
        ch <- 1 
        fmt.Println("写入成功") // 永远不会执行到这里
    }()

    // 启动一个 HTTP 服务
    // 这会让 Runtime 认为程序一切正常
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello"))
    })

    fmt.Println("Server started at :8080")
    http.ListenAndServe(":8080", nil)
}

结果:网页能正常访问,但后台那个协程已经永久挂起了。这就是典型的局部死锁

深入原理:Runtime 到底检测了什么?

为什么 Go 检测不到上面这些死锁?我们需要看看源码。

Go 的死锁检测主要逻辑在 runtime/proc.gocheckdead() 函数中。它不是去构建复杂的“资源分配图”(Resource Allocation Graph)来检测环路(因为那样太耗费性能了),而是采用了一种计数器的方式。

简单来说,Runtime 维护了几个关键指标:

  1. mcount(): 系统线程数。
  2. runningG: 正在运行的 Goroutine 数量。
  3. timers: 系统中活跃的定时器数量。
  4. netpoll: 网络轮询器是否有事件等待。

检测逻辑伪代码如下:

func checkdead() {
    // 1. 如果还有线程在运行(比如正在执行系统调用),不算死锁
    if mcount() > 0 { return }

    // 2. 如果还有 Goroutine 在 running 状态,不算死锁
    if runningG > 0 { return }

    // 3. 如果还有定时器没触发,说明可能在 sleep 等待唤醒,不算死锁
    if timers > 0 { return }

    // 4. 如果网络轮询器里还有东西,也不算死锁
    if netpollWaiters > 0 { return }

    // 5. 如果以上都没有,说明所有人都在睡觉,也没人定了闹钟,也没人等电话
    // 那就是真的死透了
    throw("all goroutines are asleep - deadlock!")
}

实际源码内容(基于 1.18 版本):

// Check for deadlock situation.
// The check is based on number of running M's, if 0 -> deadlock.
// sched.lock must be held.
// 检查死锁情况。
// 检查主要基于正在运行的 M (系统线程) 的数量,如果是 0,则可能死锁。
// 调用此函数前必须持有全局调度锁 sched.lock。
func checkdead() {
    // 1. 防御性检查:确保调度器的锁已经被锁住,保证并发安全
    assertLockHeld(&sched.lock)

    // For -buildmode=c-shared or -buildmode=c-archive it's OK if
    // there are no running goroutines. The calling program is
    // assumed to be running.
    // 2. 特殊模式豁免
    // 如果 Go 是以库的形式被 C/C++ 程序调用 (c-shared/c-archive),
    // 即使 Go 这边没有协程在跑,宿主程序可能还在运行,所以不能报死锁。
    if islibrary || isarchive {
        return
    }

    // If we are dying because of a signal caught on an already idle thread,
    // freezetheworld will cause all running threads to block.
    // And runtime will essentially enter into deadlock state,
    // except that there is a thread that will call exit soon.
    // 3. 恐慌状态豁免
    // 如果程序已经在 Panic 处理流程中了(panicking > 0),
    // 此时系统会冻结所有线程,看起来像死锁,但其实是在打印崩溃堆栈。
    // 为了避免死锁报错掩盖了真正的 Panic 原因,直接返回。
    if panicking > 0 {
        return
    }

    // If we are not running under cgo, but we have an extra M then account
    // for it. (It is possible to have an extra M on Windows without cgo to
    // accommodate callbacks created by syscall.NewCallback. See issue #6751
    // for details.)
    // 4. 修正运行中的 M 数量 (run0)
    // 在没有 CGO 的情况下,Windows 可能会有额外的 M 用于处理系统回调。
    // 如果有这种情况,我们将基准运行线程数 run0 设为 1,表示允许这种情况存在。
    var run0 int32
    if !iscgo && cgoHasExtraM {
        mp := lockextra(true)
        haveExtraM := extraMCount > 0
        unlockextra(mp)
        if haveExtraM {
            run0 = 1
        }
    }

    // 5. 核心判断:计算正在干活的 M (线程)
    // mcount(): 总线程数
    // nmidle: 处于空闲状态的 M
    // nmidlelocked: 处于锁定状态(Locked to G)且空闲的 M
    // nmsys: 处于系统调用或系统任务中的 M
    // run 表示:除去空闲和系统的,真正正在执行 Go 代码的线程数。
    run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys

    // 如果正在运行的线程数 > run0 (通常是0),说明还有线程在干活,没死锁,返回。
    if run > run0 {
        return
    }

    // 数据一致性校验:如果算出来运行线程是负数,说明 Runtime 内部状态乱了,直接抛异常。
    if run < 0 {
        print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
        throw("checkdead: inconsistent counts")
    }

    // 6. 遍历所有 G (Goroutine) 检查状态
    // 既然没有 M 在跑了,我们看看 G 都在干嘛。
    grunning := 0
    forEachG(func(gp *g) {
        // 忽略系统级 Goroutine (如 GC 的后台标记 worker,sysmon 等)
        if isSystemGoroutine(gp, false) {
            return
        }

        s := readgstatus(gp)
        // 检查 G 的状态
        switch s &^ _Gscan {
        case _Gwaiting,
            _Gpreempted:
            // 如果 G 处于等待中 (Waiting) 或 被抢占 (Preempted),
            // 说明这是一个有效的、未完成的用户任务,计数器 +1。
            grunning++
        case _Grunnable,
            _Grunning,
            _Gsyscall:
            // 如果发现有 G 还是 Runnable/Running/Syscall 状态,
            // 但前面的步骤却判断没有 M 在运行,这属于 Runtime 的逻辑矛盾。
            // 理论上 checkdead 不应该在这种状态下被调用或者走到这一步。
            print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
            throw("checkdead: runnable g")
        }
    })

    // 7. 检查是否主协程退出了
    // 如果遍历完发现没有用户 G 了 (grunning == 0),
    // 比如 main 函数调用了 runtime.Goexit() 导致主协程结束但其他协程也没了,
    // 抛出特定错误。
    if grunning == 0 { // possible if main goroutine calls runtime·Goexit()
        unlock(&sched.lock) // 解锁,避免打印日志时挂死
        throw("no goroutines (main called runtime.Goexit) - deadlock!")
    }

    // Maybe jump time forward for playground.
    // 8. Go Playground 特殊处理
    // Go Playground 为了快进时间(比如 sleep 1小时不用真等1小时),
    // 在这里会修改 faketime。如果能调整时间唤醒某个 timer,就唤醒它并返回,不算死锁。
    if faketime != 0 {
        when, _p_ := timeSleepUntil()
        if _p_ != nil {
            faketime = when
            for pp := &sched.pidle; *pp != 0; pp = &(*pp).ptr().link {
                if (*pp).ptr() == _p_ {
                    *pp = _p_.link
                    break
                }
            }
            mp := mget()
            if mp == nil {
                // There should always be a free M since
                // nothing is running.
                throw("checkdead: no m for timer")
            }
            mp.nextp.set(_p_)
            notewakeup(&mp.park)
            return
        }
    }

    // There are no goroutines running, so we can look at the P's.
    // 9. 最后一道防线:检查定时器 (Timer)
    // 走到这里说明:没有 M 在跑,所有 G 都在睡觉。
    // 唯一的希望就是定时器了。如果某个 P (处理器) 上挂着定时器,
    // 说明程序只是在 Sleep,时间到了自然会醒,所以不算死锁。
    for _, _p_ := range allp {
        // 如果 P 的定时器堆里有任务
        if len(_p_.timers) > 0 {
            return
        }
    }

    // 10. 宣判死刑
    // 没有 M 运行,所有 G 都在睡觉,没有定时器会响,也不是库模式。
    // 程序彻底死掉了。
    getg().m.throwing = -1 // 标记不打印完整的堆栈信息 (避免刷屏)
    unlock(&sched.lock)    // 解锁
    throw("all goroutines are asleep - deadlock!")
}

结论:
Go 的检测机制本质上是一个“兜底策略”。它检测的是“程序彻底停止运转”这一极端状态,而不是检测“逻辑上的死锁”。只要还有一个 G 在呼吸,它就认为你没死。

如何避免与检测死锁?

既然自带的检测不靠谱,我们在开发中就需要依靠规范和工具。

1. 避免死锁的建议

  • 规范锁的顺序:这是最根本的解决之道。如果多个协程需要获取多把锁,必须保证所有协程获取锁的顺序是一致的(例如永远先拿 A 锁,再拿 B 锁)。
  • 减小锁粒度:只在临界区加锁,不要把耗时的 IO 操作(如文件读写、网络请求)放在锁里面。
  • 使用 Select + Timeout:不要傻傻地一直等 channel 或锁。

    select {
    case req <- data:
        // 成功
    case <-time.After(time.Second):
        // 超时降级,避免卡死
        fmt.Println("timeout")
    }
  • 严禁 RWMutex 递归:读锁内部不要再去申请读锁,这在有写锁等待时会导致死锁。

2. 检测死锁的方法

对于 Runtime 无法检测的隐形死锁,我们需要主动出击:

  • pprof (神器)
    这是排查 Go 并发问题最有效的工具。在程序中开启 pprof:

    import _ "net/http/pprof"
    go func() { http.ListenAndServe(":6060", nil) }()

    当怀疑死锁时,访问 http://localhost:6060/debug/pprof/goroutine?debug=1。查看 Goroutine 的堆栈信息,如果你发现大量的 Goroutine 停留在 semacquire(等待锁)或者 chan send 状态,那就是死锁了。

  • go-deadlock 库
    这是一个第三方的 debug 库 (github.com/sasha-s/go-deadlock)。你可以用它替换原生的 sync.Mutex。如果在开发环境中锁等待超过指定时间(默认 30s),它会自动 dump 出堆栈信息,明确告诉你死锁发生在哪里。


总结:Go 语言自带的死锁检测只是最后一道防线,防止程序在彻底无响应时变成“僵尸进程”。在复杂的业务开发中,我们绝不能依赖它来发现 Bug,良好的编码规范和熟练使用 pprof 才是王道。

🔲 ☆

为什么我写的越来越少了

概述

细心的朋友可能发现了,最近博客的更新频率明显降低了。

前几天看到一篇文章《Blogging in 2025: Screaming into the void》,读完之后深有感触。文中提到了“对着虚空呐喊”的感觉,这其实也正是我当下的心境。

回想以前,写博客是一件非常有成就感的事情,但自从 AI 技术爆发式增长以来,我发现自己提笔(或者说敲键盘)的动力在逐渐消失。今天就来聊聊,为什么在 AI 时代,我写的越来越少了。

核心原因分析

仔细思考了一下,导致这种变化的并不是懒惰,而是整个技术环境和获取信息的逻辑发生了根本性的改变。主要可以归纳为以下几点:

1. AI 的全能与降维打击

通俗的讲,AI 现在太强了。

以前遇到一个棘手的 Bug 或者一个晦涩的配置,我们需要查阅官方文档、逛 Stack Overflow,然后整理成一篇博文,既是记录也是分享。但现在,AI 能做的实在太多了。无论是写代码、查文档,还是解释复杂的概念,它都能在几秒钟内给出高质量的答案。

当一个工具能瞬间完成你过去需要几个小时才能总结出来的成果时,继续手工“造轮子”的挫败感是很强的。

2. 投入产出比(ROI)的失衡

写一篇高质量的技术博客,成本其实很高。你需要:

  1. 构思大纲
  2. 编写代码 demo
  3. 组织通俗易懂的语言
  4. 排版 markdown

在 AI 出现之前,这些投入是值得的,因为它们创造了“稀缺性”的内容。但现在,投入大量精力来做这件事的意义已经不大了。如果我花费一下午写的内容,读者直接问 ChatGPT 就能得到更好、更定制化的答案,那么我的投入又有什么价值呢?

3. 知识内化方式的改变

以前写博客,很大程度上是为了“整理思路,巩固自己的知识”。我们常说“教是最好的学”,通过输出倒逼输入。

但在有了 AI 之后,很多东西都不再需要通过“写成文章”这种重度方式来巩固了。

  • 以前:必须把知识点记下来,因为怕忘了找不到。
  • 现在:学习的成本变低了,检索的成本更是几乎为零。

当然,这并不是说不需要学习了,而是说学习的路径变了。很多知识变成了“即用即查”的临时态,不再需要通过博客这种形式固化下来。

4. 读者的流失与“虚无感”

这可能是一个更扎心的事实:现在阅读博客的人真的少了。

大家都习惯了快节奏的信息流,或者习惯了直接向 AI 索取答案。别说是陌生读者,就连我自己,现在也不怎么阅读自己以前写的博客内容了。以前遇到问题会去翻自己的博客找解决方案,现在我也是直接问 AI。

既然连作者本人都不怎么看了,那坚持更新又是在写给谁看呢?这大概就是那篇文章里说的“Screaming into the void”(对着虚空呐喊)吧。


虽然文章写的少了,但对技术的探索并不会停止。也许未来的某一天,我会找到一种新的记录方式,或者,只是单纯的享受技术本身,而不再执着于“记录”这个形式。

🔲 ☆

众包体验

最近这段时间,我给自己找了份新“兼职”——美团众包。

先说清楚,倒真不是因为缺钱缺到揭不开锅。我也不是为了什么“体验生活”这种听起来高高在上的理由。我只是想短暂逃离那个恒温 26 度的办公室,去粗粝的现实里摩擦一下,看看脱下了“白领”这层皮,我到底是谁。

但这几天的经历,像一记闷棍,狠狠敲在了我的天灵盖上。

1. 门禁卡与拦路虎:当尊严被“穿”在身上

社会上现在特别流行一种政治正确的口号:“职业不分高低贵贱”。

听听就好,谁信谁天真。这句话就像是橱窗里的精美展示品,摆在那里是为了好看,而不是为了让你用的。现实生活的逻辑,从来都是赤裸裸的“看人下菜碟”。

我想讲一个发生在我身上的真实故事,讽刺程度拉满。

【事件回放】

那是一个普通的周二晚上,我接了一个单子,终点恰好是我自己居住的高档小区。

  • 平时:我开着车,或者穿着便装刷卡进门。保安大哥通常会离得老远就敬礼,甚至笑容满面地帮我拉开单元门,那一声“您好”喊得中气十足。
  • 那天:我骑着贴满反光条的电动车,戴着那顶醒目的黄色头盔,手里提着外卖袋,正准备从那个我走了无数遍的大门进去。

结果,我被拦住了。

保安大哥——那个平时对我笑脸相迎的人——此刻横跨一步,用一种我从未见过的、充满审视和不耐烦的眼神看着我:“干嘛的?外卖不让进。”

我试图沟通:“师傅,帮帮忙,这单马上超时了。我就送到 3 号楼,我跑着去跑着回,两分钟。”

他的回答冷硬得像一块石头:“超时是你自己的事。规定就是规定,外卖员不能进,打电话让业主出来拿。”

我当时急了,脱口而出:“我就住这儿,我对路很熟……”

他直接打断了我,语气里满是鄙夷:“你一看就是送外卖的,别废话,赶紧出去。”

【荒诞的对比】

就在我们僵持的那几十秒里,一位业主骑着电动车长驱直入。他没戴头盔,车也没上牌,但保安不仅没拦,还殷勤地按开了道闸。

这一刻,我感到一种巨大的割裂感:

  1. 白天的我:西装革履,是这里的“尊贵业主”,是拥有话语权的人。
  2. 晚上的我:换上黄马甲,就成了这里的“闯入者”,是需要被防范、被管理的底层劳力。

明明是同一个人,同一个身体,同一个灵魂。仅仅因为我换了一身衣服,换了一种谋生工具,我就从“人”变成了“麻烦”。

这让我深刻意识到,所谓的“社会地位”,其实大部分时候是附着在你的衣着、座驾和职业标签上的。

那个保安针对的不是我这个具体的人,而是“外卖员”这个符号。在他的认知逻辑里,阻拦外卖员是展示他权力的时刻,也是他维护小区“档次”的方式。这是一种弱者对弱者的为难,因为他无法对开豪车的业主发火,只能把生活的怨气撒在比他看起来更卑微的人身上。

2. 也是有温度的:底层江湖的“抱团取暖”

虽然在小区的门禁处碰了一鼻子灰,但在路上,在那些被城市折叠的角落里,我却意外地感受到了久违的温情。

作为一个新手(小白),我对很多商家的位置简直是一头雾水。有些店铺藏在老旧居民楼的深处,有些在地下迷宫般的美食城角落,导航在那里经常失灵,我就像个无头苍蝇一样乱转。

那种焦虑是生理性的:眼看着配送倒计时从绿色变成刺眼的红色,每一秒跳动都像是在催命。

在这些崩溃的时刻,拯救我的往往是路边的陌生同行。

  • 指路:有次我在一个巷子里迷路,问了一个正急着取餐的小哥。他明明急得满头大汗,还是停下来,指着一个不起眼的铁门说:“别走正门,那是坑!走这个侧门上二楼,左拐到底就是!”
  • 传授经验:还有次我不知道怎么把两杯奶茶固定在箱子里,旁边一个大叔直接走过来,教我怎么用隔板卡住:“这样放,过减速带都不会洒。”

这让我非常触动。你要知道,在外卖这个行业里:

  • 时间就是金钱:每一秒的耽搁,都可能意味着这一单白跑了,甚至还要倒贴罚款。
  • 竞争关系:理论上,我们是在抢同一个池子里的单子。

但他们依然愿意停下来,哪怕只为了帮我节省几十秒。

为什么?我觉得原因有三点:

  1. 感同身受的苦:大家都在这泥潭里滚过,都知道那个倒计时的红色数字有多搞人心态。也没谁天生就是活地图,都是从小白熬过来的。
  2. 底层的体面:正因为被外界冷眼相看,这个群体内部反而生出一种江湖义气。既然上面的人不给面子,那我们自己得互相帮衬。
  3. 纯粹的善意:这种善意没有功利心,不是为了社交,不是为了拓展人脉,纯粹就是看你着急,搭把手。

这种粗粝的、带着汗水味的善意,比写字楼里那些客套的“改天请你吃饭”,要真实一万倍。

3. 只有吃饱了,才配谈诗和远方

晚上骑着车,穿行在空旷的马路上,耳边只有风声呼啸。

那时候,我的脑子里在想什么?

并没有想什么宏大的叙事,也没有想公司的战略规划。我的脑子里只有非常具体、非常琐碎的算计:

  • 这单能挣 4 块 5 还是 5 块 2?
  • 电瓶车的电量能不能撑到下一个换电柜?
  • 前面路口有没有交警查头盔?
  • 这一单送完,我就能买一瓶冰可乐喝了。

我突然理解了那个著名的“马斯洛需求层次理论”。

我们在朋友圈里发着精修的旅行照,在咖啡馆里谈论着“人生的意义”、“自我实现”、“内心的宁静”,那是因为我们已经站在了金字塔的腰部以上。我们已经默认解决了温饱问题,所以才有闲情逸致去痛苦、去迷茫、去矫情。

但对于此刻骑在车上的我,或者是对于千千万万以此为生的骑手来说,“生存”这个词,是具象化的:

  • 它是多跑一单就能给孩子买个新书包;
  • 它是少得一个差评就能保住这一周的奖金;
  • 它是希望能平平安安跑完这一天,别出车祸。

当生活变得捉襟见肘,当你的眼前只有一单又一单的任务时,你的视野会被强制收窄。你看不见远处的风景,你只能看见脚下的路和手机屏幕上的路线图。

贫穷最可怕的地方,不在于没钱,而在于它会剥夺你“思考”的权利。 它让你陷入一种为了生存而无限循环的忙碌中,耗尽你的心力,让你没有余力去规划未来,去提升自己,去追求那些“无用”的美好。

4. 两个我,一种人生

这次“职业分身”的体验,虽然不长,但后劲很大。

我开始审视我的生活,我发现我其实活在一种微妙的双重人格里:

  • 白天的我:坐在明亮的写字楼里,讨论着千万级的项目,喝着几十块一杯的咖啡,觉得保安的问好是理所当然,觉得快递慢了一点就不可理喻。
  • 晚上的我:在寒风里瑟瑟发抖,为了几块钱狂奔,被小区保安呵斥,被顾客嫌弃,却因为陌生人的一句指路而感动半天。

到底哪一个才是真实的世界?

我想,两个都是。只是我们平时太习惯待在自己的舒适区里,以至于忘了墙外面还有另一个世界。

最后,我想给自己,也给看这篇文章的朋友们几点真诚的建议:

  1. 别太高估自己的“尊贵”:脱了那身西装,离了那个平台,我们可能什么都不是。保安敬礼敬的是你的房子和车子,不是你这个人。
  2. 保持对他人的基本尊重:下次外卖晚了点,或者小哥找不到路,别急着发火。他们可能刚刚在寒风里等了半小时商家出餐,或者刚刚被某个小区的门禁刁难过。多一句“谢谢”,多一句“注意安全”,真的能温暖一个人很久。
  3. 珍惜当下的选择权:如果你现在有时间迷茫“人生的意义”,说明你还算幸运。因为这个世界上有很多人,光是为了“活着”,就已经花光了所有力气。

白天和晚上的我都是我,但又好像不是一个我。

也许,这就是生活的真相。我们都在扮演不同的角色,但无论拿的是什么剧本,努力生活的人,都值得一个五星好评。

🔲 ⭐

离线临时密码的生成

前言

最近看到朋友家装了新的只能门锁, 可以使用指纹或密码解锁. 密码解锁没什么, 但其在手机端可以生成一个临时密码倒是引起了我的兴趣. 这个场景是这样的:

  1. 密码锁不能联网
  2. 生成密码时, APP与密码锁没有通讯(不在一起, 且密码锁不能联网)
  3. APP 和密码锁仅在首次配对的时候进行过通讯
  4. 临时密码有效期为30分钟, 从密码的生成时间开始计算
  5. 手机端可以多次生成临时密码, 每个都不一样且有效

那么问题来了, 密码锁是如何验证密码是否有效的呢?

实现方案思考

方案一: 预生成

思路:

  1. APP与密码锁在首次配对的时候, 预先生成1万个临时密码
  2. 每次获取临时密码时, 从预生成列表中获取下一个即可

问题:

  1. 密码无法与时间绑定, 无法实现"临时"的需求
  2. 密码用尽后需要重新配对

方案二: TOTP

思路:

  1. 基于动态密码 TOTP 方案实现
  2. 在配对的时候同步共享密码与计数器
  3. 计数器每30分钟+1

问题:

  1. TOTP为固定时间窗口, 无法实现动态窗口
  2. 计数器每30分钟+1, 无法多次生成临时密码

方案三: 增强型 TOTP

到这里其实已经有方案了, TOTP是固定窗口, 那多个固定窗口连在一起, 不就是个滑动窗口么.

思路:

  1. 仍然基于 TOTP 的方案, 在配对时同步共享密钥和计数器
  2. 计数器每5秒+1
  3. 密码验证时, 验证当前时间向前推30分钟的所有 TOTP密码, 若与其中一个匹配即为可用

若计数器每5秒+1, 则30分钟可产生的密码数量为: (30min / 5s)=360

这个方案是我想到能够满足此场景的. 优势:

  1. 双方无需存储密码队列, 无存储开销
  2. 能够把握30分钟的滑动窗口, 误差为5秒
  3. 每5秒可生成一个新的动态密码, 支持重复生成

潜在问题:

  1. 时间同步: 密码锁长时间无对外通讯, 时间同步可能产生误差. 可在每次通讯时调整, 或适当放宽验证的时间范围以平衡时钟误差
  2. 计算资源: 每次密码验证需要进行360次 TOTP 计算. 可考虑在密码锁中维护一个密码队列, 每5秒生成密码添加到队列中, 并将旧密码出队列. 这样验证密码的时候就不需要频繁计算了

以上, 就是基于这个场景进行的设想, 其具体是如何实现的俺就不晓得了.

🔲 ☆

为什么不建议使用goto

前提

最近在公司代码review过程中, 看到同事的代码中大量使用了goto, 我给出了"不用 goto"的建议. 但其给出的理由是使用goto更简单. 确实, 使用goto可以使得逻辑更简单直接, 但前提是不乱用goto, 而在公司的项目中又很难保证这一点.

问题

使用goto带来的最直观的问题就是逻辑的复杂度直线升高. 举个例子来展现goto是如何一步步导致逻辑破败不堪的. (当然, 这个例子是我臆想出来的场景)

首先, 我们有一个创建订单并验证支付的需求:

package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

    fmt.Println("Step 1: 创建订单")

    fmt.Println("Step 2: 验证订单")

    fmt.Println("Step 3: 验证付款信息")

    fmt.Println("Step 4: 订单完成")

    fmt.Println("处理订单结束")
}

此时逻辑很清楚吧. 现在, 我们要对验证订单的结果进行处理, 如果验证失败, 则进行错误处理, 很合理吧:

package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

    fmt.Println("Step 1: 创建订单")

    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        goto Fail
    }

    fmt.Println("Step 3: 验证付款信息")

    fmt.Println("Step 4: 订单完成")
    goto End
Fail:
    fmt.Println("验证付款失败")
End:
    fmt.Println("处理订单结束")
}

现在, 新的需求来了:

  1. 付款信息处理可能因各种原因失败, 需要重试, 最多重试3次
  2. 验证订单也可能失败(异步接口验证, 网络抖动等), 需要重试, 最多重试3次
  3. 若订单验证失败, 需要提示并重新创建订单
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
ValidOrder:
    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrder
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

    checkRetryNum := 0
CheckOrder:
    var checkErr error
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrder
        }
        goto CheckErr
    }

    fmt.Println("Step 4: 订单完成")
    goto End

CheckErr:
    fmt.Println("付款信息验证失败")
End:
    fmt.Println("处理订单结束")
}

再来:

  1. 验证付款信息失败, 可能是因为没有付款等, 需要进行付款处理的逻辑
  2. 验证订单付款时, 订单可能认为取消支付, 需要处理资源清理等
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
ValidOrder:
    var validErr error
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrder
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

    checkRetryNum := 0
CheckOrder:
    var checkErr error
    var paid, cancel bool
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrder
        }
        goto CheckErr
    }
    if cancel {
        goto CancelOrder
    }
    if !paid {
        goto ProcessOrder
    }

    fmt.Println("Step 4: 订单完成")
    goto End

CheckErr:
    fmt.Println("付款信息验证失败")
    goto End

CancelOrder:
    fmt.Println("取消订单支付")
    goto End

ProcessOrder:
    fmt.Println("处理付款信息")
    goto CheckOrder

End:
    fmt.Println("处理订单结束")
}

再来

  1. 增加处理失败的日志记录
  2. 增加订单验证失败的日志记录
  3. 不管是取消订单支付, 还是付款处理失败, 都需要进行一些清理工作
package main

import "fmt"

func main() {
    fmt.Println("处理订单开始")

CreatOrder:
    fmt.Println("Step 1: 创建订单")

    validRetryNum := 0
    checkRetryNum := 0
    var checkErr error
    var validErr error
    var paid, cancel bool
ValidOrder:
    fmt.Println("Step 2: 验证订单")
    if validErr != nil {
        validRetryNum++
        if validRetryNum <= 3 {
            goto ValidOrderErrorLog
        }
        fmt.Println("订单验证失败")
        goto CreatOrder
    }

CheckOrder:
    fmt.Println("Step 3: 验证付款信息")
    if checkErr != nil {
        checkRetryNum++
        if checkRetryNum <= 3 {
            goto CheckOrderErrorLog
        }
        goto CheckErr
    }
    if cancel {
        goto CancelOrder
    }
    if !paid {
        goto ProcessOrder
    }

    fmt.Println("Step 4: 订单完成")
    goto End

ValidOrderErrorLog:
    fmt.Println("记录订单验证失败")
    goto ValidOrder

CheckOrderErrorLog:
    fmt.Println("记录付款验证失败")
    goto CheckOrder

CheckErr:
    fmt.Println("付款信息验证失败")
    goto CleanOrder

CancelOrder:
    fmt.Println("取消订单支付")
    goto CleanOrder

ProcessOrder:
    fmt.Println("处理付款信息")
    goto CheckOrder

CleanOrder:
    fmt.Println("订单关闭的清理工作")
    goto End

End:
    fmt.Println("处理订单结束")
}

现在, 如果你还觉得逻辑清晰, 那我只能说一句"牛".

代码演进到现在, 逻辑已经十分混乱了, 逻辑的混乱会导致一系列问题:

  1. 难以理解, 逐步增加后续迭代的成本
  2. 造成额外的心智负担
  3. 追踪困难
  4. 如果是if for 在逻辑上是自上而下的, 但引入 goto会导致逻辑上下横跳
  5. 等等

可能有人会觉得我举的例子有些极端, 实际中没有人会这么做. 那是因为例子总是简单化的, 现实中的场景实际上要更加复杂:

  1. 大段逻辑分散: 例子中的所有单条print语句, 在实际项目中都可能会对应一大段的逻辑
  2. 最小改动原则: 对现有项目进行改动的时候(尤其是需求要的比较急, 要求改动最小实现功能), 对现有代码的改动越小, 则风险越小. 因此逻辑会越堆越难以理解, 直至最后无法使用
  3. 每个人的水平不同: 同一份代码会由团队中的不同人在不同时间维护, 即使你自信自己的水平, 也无法保证代码在未来不会向着这个方向发展

使用场景

当然, 我也不是把goto一棒子打死, 同事在反驳我的时候也给出了强有力的理由"Go 标准库也存在大量使用goto的地方". 比如:

func ParseMAC(s string) (hw HardwareAddr, err error) {
    if len(s) < 14 {
        goto error
    }

    if s[2] == ':' || s[2] == '-' {
        if (len(s)+1)%3 != 0 {
            goto error
        }
        n := (len(s) + 1) / 3
        if n != 6 && n != 8 && n != 20 {
            goto error
        }
        // ...
    } else if s[4] == '.' {
        if (len(s)+1)%5 != 0 {
            goto error
        }
        n := 2 * (len(s) + 1) / 5
        if n != 6 && n != 8 && n != 20 {
            goto error
        }
        // ...
    } else {
        goto error
    }
    return hw, nil

error:
    return nil, &AddrError{Err: "invalid MAC address", Addr: s}
}

这种其实是可以接受的, 能够简化流程, 但是, 但是, 不要忘记我不建议使用goto最重要的一点:

  1. 你无法保证自己拥有掌控goto的实力
  2. 即使你自信, 也无法保证同事有掌控goto的实力

一旦在使用过程中产生破窗效应, 使用goto破坏的速度一定是比不用要快的多. 代码很快就会脱离掌控.

因此, 为了避免这种情况, 最好的方式就是在最开始杜绝掉.

最后的最后, goto如果能够好好用的话, 确实能够带来一定的便利性, 前提是项目由你一人开发, 或你拥有掌控权可以拒绝某些腐败代码进入代码库.

goto并不可怕, 可怕的是不加限制的乱用goto.


欢迎来辩…

🔲 ⭐

人生的意义

闲来无事, 会一个人散散步, 也没什么目的, 就是随便走走. 有时会想想工作上的事, 有时也会想想其他的. 比如, 人生的意义

说起来我现在的岁数应该还没到要想这种问题的时候, 但也不尽然, 各个年龄段会有这不同的经历, 不同的见解, 不同的意境, 也会给出不同的答案.

忘记是在哪里看到这样一个故事: 如果一个人生命垂危, 是应该让他躺在医院的病床上插满各种管子, 这可能会让他继续活很长时间, 还是应该放弃治疗, 去享受生命最后的时光呢?

很多人会想, 我才不要在病床上屈辱的活着, 那和死亡又有什么区别. 如果是我, 我一定会让生命最后的时间轰轰烈烈.

但是, 有这种想法的人应该大多数都没有经历过这种抉择(包括我), 谁又能保证在死亡的恐惧面前不会让步? 谁又能保证活下去的憧憬不会填满内心? 再退一步, 即使放弃了治疗, 我相信也很难做到轰轰烈烈, 很可能是一段平静的生活.

当然, 在这里我并没有去评价选择的高低, 我自己也不清楚我会如何抉择. 我只是借此在想, 人生的意义是什么?

人生的意义是在于长度? 在于质量? 在于身后名? 在于自我评价? 在于自由? 还是?

同样的, 对于幸福, 过着采集狩猎的古人有大把的时间用来放空, 用来虚度, 谁又能说的清他们和我们谁更幸福呢?

我想, 这个问题已经超出了科学的范畴, 科学永远无法给出这个答案, 每个人的答案可能都不一样. 科学也许可以从理性的角度给出一个最优解, 但永远无法给出最好的解.

这个问题从先哲苏格拉底就在思考了, 一直到现在两千多年过去了仍然没有答案, 我想未来也不会有答案.

思来想去, 仍旧逃不出那四个字"活在当下". 未经反省的人生是不值得过的,但是过度考察的人生是没法过的人生!人生的意义更多的是来自内心的主观判断. 所以, 可以尝试这去思考答案, 但不要刻意去追逐人生的意义和价值.

🔲 ⭐

聊聊 Go 边界检查消除

前言

这篇文章中碰巧看到了Go边界检查消除相关的讨论. 我也借此简单聊聊.

有这样一段代码, 非常简单, 就是一段求向量点积的程序:

func sum(a, b []int) int {
    if len(a) != len(b) {
        panic("must be same len")
    }
    ret := 0
    for i := 0; i < len(a); i++ {
        ret += a[i] * b[i]
    }
    return ret
}

根据之前CPU 流水线的原理, 将其在数组内部展开可以提高循环计算效率:

package main

func sum(a, b []int) int {
    if len(a) != len(b) {
        panic("must be same len")
    }
    ret := 0
    for i := 0; i < len(a); i += 4 {
        s1 := a[i] * b[i]
        s2 := a[i+1] * b[i+1]
        s3 := a[i+2] * b[i+2]
        s4 := a[i+3] * b[i+3]
        ret += s1 + s2 + s3 + s4
    }
    return ret
}

到这里, 就要引出Go边界检查的概念了. 我们都知道, 在数组访问越界的时候会触发panic, 这个其实是编译期在编译期间额外添加边界检查代码实现的. 可以给go build命令添加-gcflags='-d=ssa/check_bce'参数来查看哪些地方触发了边界检查:

image-20240225130046891

我们可以理解为, 上面的程序在编译后是这样的:

func sum(a, b []int) int {
    if len(a) != len(b) {
        panic("must be same len")
    }
    ret := 0
    for i := 0; i < len(a); i += 4 {
        if i >= cap(a) || i >= cap(b) {
            panic("out of bounds")
        }
        s1 := a[i] * b[i]
        if i+1 >= cap(a) || i+1 >= cap(b) {
            panic("out of bounds")
        }
        s2 := a[i+1] * b[i+1]
        if i+2 >= cap(a) || i+2 >= cap(b) {
            panic("out of bounds")
        }
        s3 := a[i+2] * b[i+2]
        if i+3 >= cap(a) || i+3 >= cap(b) {
            panic("out of bounds")
        }
        s4 := a[i+3] * b[i+3]
        ret += s1 + s2 + s3 + s4
    }
    return ret
}

在每次数组访问前都会进行边界检查.

而如果我们将其改造成这样, 就只需要2次边界检查.

func sum(a, b []int) int {
    if len(a) != len(b) {
        panic("must be same len")
    }
    ret := 0
    for i := 0; i < len(a); i += 4 {
        aTmp := a[i : i+4] //  Found IsSliceInBounds
        bTmp := b[i : i+4] //  Found IsSliceInBounds
        s1 := aTmp[0] * bTmp[0]
        s2 := aTmp[1] * bTmp[1]
        s3 := aTmp[2] * bTmp[2]
        s4 := aTmp[3] * bTmp[3]
        ret += s1 + s2 + s3 + s4
    }
    return ret
}

场景

简单列一些边界检查的场景, 仅供参考:

func check(a []int, b [5]int, i int) {
    // 重复访问
    _ = a[2] // Found IsSliceInBounds
    _ = a[2] // 重复访问, 消除边界检查

    // 长度判断
    if 3 < len(a) {
        _ = a[3] // 提前判断长度, 无需边界检查
    }

    // 常量数组
    _ = b[4] // 固定长度数组, 无需边界检查

    // 提前边界检查
    _ = a[5] // Found IsSliceInBounds
    _ = a[4] // 因为上边检查过5, 所以这里无需边界检查
    _ = a[3]
}

如果足够自行, 我们也可以在编译的时候添加参数-gcflags=-B 来禁用边界检查.

这篇文章中有一些其他场景供参考.

OK, 这里抛砖引玉, 简单说一下边界检查这玩意, 感兴趣的也可以查看编译后的汇编代码来了解具体是如何进行边界检查的.

🔲 ⭐

使用Go实现一个百行聊天服务器

前段时间, redis作者不是整了个c语言版本的聊天服务器嘛, 地址, 代码量拢共不过百行.

于是, 心血来潮下, 我也整了个Go语言版本. 简单来说就是实现了一个聊天室的功能. 将所有注释空行都去掉, 刚好100行实现. 废话不多说, 先上代码:

package main

import (
    "fmt"
    "log"
    "net"
    "strings"
    "sync"
)

type Server struct { // 服务端内容
    clients map[string]*Client
    lock    sync.Mutex
}

func (s *Server) delClient(client *Client) { // 客户端关闭
    s.lock.Lock()
    defer s.lock.Unlock()
    delete(s.clients, client.name)
}

func (s *Server) addClient(client *Client) { // 客户端关闭
    s.lock.Lock()
    defer s.lock.Unlock()
    s.clients[client.name] = client
}

func (s *Server) sendMsgToOtherClient(msg string, client *Client) { // 将消息发送给其他所有客户端
    s.lock.Lock()
    defer s.lock.Unlock()
    // 将消息转发给其他客户端
    for _, c := range s.clients {
        if c != client {
            c.msgCh <- "msg-> " + client.name + ": " + msg + "\n"
        }
    }
}

type Client struct { // 定义客户端
    conn   net.Conn
    name   string      // 当前客户端的名称
    msgCh  chan string // 发送消息的管道
    server *Server
}

func (c *Client) receive() { // 接收消息
    for msg := range c.msgCh {
        _, _ = c.conn.Write([]byte(msg))
    }
}

func (c *Client) close() {
    c.server.delClient(c)
    close(c.msgCh)
    _ = c.conn.Close()
}

func (c *Client) handle() { // 开始处理连接
    _, _ = c.conn.Write([]byte(fmt.Sprintf("hello %s!\n", c.name))) // 发送欢迎信息
    c.server.sendMsgToOtherClient("join", c)                        // 通知大家, 有人加入了聊天室
    defer c.close()
    for {
        buf := make([]byte, 2048)
        n, err := c.conn.Read(buf) // 接收客户端发送的消息
        if err != nil {
            log.Printf("receive client data error: %s", err.Error())
            return
        }

        msg := strings.TrimSpace(string(buf[:n]))
        if len(msg) == 0 {
            continue
        }
        if msg == "quit" {
            c.server.sendMsgToOtherClient("quit", c) // 通知大家, 有人退出了聊天室
            return
        }
        c.server.sendMsgToOtherClient(msg, c)
    }
}

func main() {
    // 监听端口
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        panic(err)
    }
    server := &Server{
        clients: make(map[string]*Client),
        lock:    sync.Mutex{},
    }

    nameIndex := 1
    for {
        conn, err := listener.Accept() // 建立连接
        if err != nil {
            log.Println(err)
            continue
        }

        client := &Client{
            conn:   conn,
            msgCh:  make(chan string, 100),
            name:   fmt.Sprintf("user%d", nameIndex),
            server: server,
        }
        nameIndex++
        server.addClient(client)
        go client.handle()
        go client.receive()
        log.Printf("new client: %s\n", conn.RemoteAddr())
    }
}

可以直接telnet作为客户端连接, 实现的功能简单来说就是一个大的聊天室, 用户发的消息会同步发给所有用户. (因代码篇幅和复杂度原因, 就不对代码做详细说明了)

看下聊天室效果:

image-20231216220013277

当然了, 还是有很多极限情况没有处理, 很多异常情况没有判断. 比如:

  • 客户端数量没有控制
  • 缺少客户端心跳
  • 缺少错误处理
  • 缺少安全性检查
  • 客户端发送消息的长度限制
  • 等等…

但总得来说, 作为一个玩具还是可以的, 而且也达到百行的要求咯.(这也是我改了几版才改到百行的..)

🔲 ⭐

正则表达式使用文档

通过网站 https://regex101.com/ 可以测试正则表达式的匹配结果及匹配过程.

本文章抛开各个编程语言实现差异, 仅做正则本身的介绍, 会尽量将正则这玩意说明白, 使得你看完这边文章后对正则基本可以运用自如.

温馨提示, 这篇文章会比较长, 大致浏览即可. 正确的方式是收藏起来, 等到使用正则的时候翻看

语法介绍

在平常进行字符串匹配的时候如何做呢? 比如希望从字符串Hello Word中匹配到第一个单词, 我们就会拿着Hello子串进行匹配.

正则表达式的表示与其相同, 区别是在子串匹配时每个字符都会进行原样匹配, 而正则表达式中会存在一些特殊符号, 这些符号会代表一些特殊的含义, 如a+的意思是匹配任意多个连续的a字符, 是不是十分简单?

如此说来, 要使用正则表达式, 关键点就在于了解其中的特殊字符上.

分类 字符 含义
单字符 . 任意字符(换行符除外)
\d 任意数字
\D 非数字
\w 字母数字下划线
\W 非字母数字下划线
$ 字符串结束位置
^ 字符串开始位置
空白符 \s 任意空白字符
\S 非空白字符(包括空格)
\r 回车
\n 换行
\f 换页
\t 制表符
\v 垂直制表符
量词 {m} 前面内容出现 m 次
{m,} >=m次
{m,n} m-n 次
* 同 {0,}
+ 同 {1,}
? 同 {0, 1}
范围选择 | 或. eg: ab|cd msg: ab 或 cd
[…] 单字符多选一. eg: [abcd] msg: a或b或c或d
[a-c] ASCII 表范围. eg: [a-z] msg: 所有小写字母
若其中的-是需要匹配的单字符, 需使用\-进行转义
[^…] 取反. []中标识的字符外的任意字符

以上所有均可以任意嵌套使用, 如:

  • https?|ftp: 可匹配 http https ftp

高级用法

分组

有这样一个正则表达式 ab{3} , 它会匹配字符串 abbb. 如果我们想要匹配字符串 ababab , 如何在正则表达式中指定令ab 重复3次呢?

用程序员的通俗思维想一下, 没错, 加括号. (ab){3} 的意思就是匹配字符串 ababab. 而这, 就是分组.

有的小朋友会问题了, 这不就是指定下优先级嘛, 和分组有什么关系? 为什么叫分组呢? 别急, 往下看

在正则表达式中, 分组有如下作用:

  1. 在表达式后面可以进行引用
  2. 在匹配结果中, 会将匹配的分组同时提取出来

正则引用分组

在正则表达式中, 可以通过 \1 来引用分组. 其中的数字是分组的编号, 从1开始. 从左往右依次递增.

比如正则表达式 (ab)(cd)\2\1 会匹配字符串 abcdcdab 同时会在结果中将2个分组提取出来.

image-20230910204040697

这里注意, 在有些编程语言的实现上, 通过$符号引用分组(比如 js), 用的时候再搜就行.

不引用分组

有的时候我们只是希望将多个字符合并, 并不需要引用分组. 这时可以通过 (?:...) 来指定不需要引用的分组.

嵌套分组

对于比较复杂的场景, 会存在括号嵌套括号的情况, 此时分组编号是全局的. 简单说, 左括号是第几个, 分组编号就是几.

比如正则表达式 (a(bc)d)\2\1 会匹配字符串 abcdbcabcd

分组应用场景

简单介绍几种可能预见的应用场景:

匹配重复单词

比如正则表达式: (\w+) \1

文本替换

比如这段python代码:

import re

test_str = 'hello, hujingnb is good, haha'
pattern = r'(\w+) is good'
repl = r'\1 is bad'
# hello, hujingnb is bad, haha
print(re.sub(pattern, repl, test_str))

比如常用的sublime工具中, 也可通过类似操作进行文本替换.

匹配模式

在匹配的时候, 可以通过设置(?i)来修改匹配模式为忽略大小写, 使用方式如下:

  • (?i)hello: 放在正则表达式最前面, 整个正则表达式均为忽略大小写模式
  • h(?i)hello: 放在正则表达式中间, 即从某处开始, 改为忽略大小写模式. 注意, 不是所有语言均可用
  • ((?i)hello) \1: 放在分组开头, 标识分某个分组改为忽略大小写模式. 注意, 不是所有语言均可用

可以调整的匹配模式如下, 匹配模式格式均为(?<model>), 使用方法相同, 多个模式可以放在一起使用, 如 (?is):

  • a: 测试 仅匹配 ASCII 字符, unicode 编码字符不进行匹配
  • i: 测试 忽略大小写
  • m: 测试 多行模式. 修改^$的行为, 改为匹配每一行的开头结尾
  • n: 测试 开启后, (...) 这种普通分组不会做为分组存在, 仅(?<name>...) 这种命名分组会进行捕获
  • s: 测试 .可以匹配任意符号, 包括换行符
  • u: 测试 匹配完整的 unicode 编码, 默认行为, 基本不需要设置
  • U: 测试 懒惰模式. 开启懒惰模式, 在此模式下, 量词后面加?为恢复贪婪模式
  • x: 测试 详细模式. 将正则表达式中的所有空格及换行均忽略, 且每行#后为注释内容. 匹配规则中的空格可使用\转义 (或者放到分组中使用, 也可以通过[ ]使用)

注意: 大部分语言都可以直接在正则表达式中修改匹配模式, 但部分语言不行, 比如:

  • js: /<正则>/<model>

边界匹配

在正则使用中, 可能会想要进行位置匹配, 但并不希望匹配内容出现在结果中. 于是就出现了这样一组符号, 仅用于匹配位置, 比如前面出现过的 ^$

用于匹配边界的符号有如下几种:

符号 demo 含义
^ 匹配 不匹配 匹配字符串的开始位置
$ 匹配 不匹配 匹配字符串的结束位置
\b 匹配 匹配单词边界, 边界包括 空格.- 等等符号, 注意_不是单词边界
\B 匹配 不匹配 匹配非单词边界, 与\b相反
\A 匹配 不匹配 ^差异 匹配字符串的开始位置, 与^相似. 但多行模式下, ^的行为会改为匹配行开始位置, \A行为不会改变
\Z 匹配 $差异 匹配字符串的结束位置, 与$相似. 同样, 在多行模式下, 行为不会改变
(?=...) 匹配 不匹配 前向肯定断言. 简单说, 右边匹配 ...
(?!...) 匹配 不匹配 前向否定断言. 简单说, 右边不匹配 ...
(?<=...) 匹配 不匹配 后向肯定断言. 简单说, 左边匹配 ...
(?<!...) 匹配 不匹配 后向否定断言. 简单说, 左边不匹配 ...

扩展语法

所有扩展语法格式均为为(?...). (反过来不成立)

注意, 扩展语法并不是所有编程语言都支持的, 在使用前可前往网站测试是否支持.

命名分组

使用编号引用分组的方式并不友好, 甚至有时候改了下正则表达式, 后面编号都要改一遍. 因此, 我们可以给分组起个名.

  • (?P<xxx>...): 给分组起名为 xxx
  • (?P=xxx): 引用 xxx分组

比如正则表达式 (?P<reg_name>ab)(?P=reg_name) 会匹配字符串 abab

注意 命名分组也会占用分组的编号哦, 也就是说 (?P<reg_name>ab)(?P=reg_name)(?P<reg_name>ab)\1 效果是一样的.

测试

分支判断

根据前面是否匹配到分组信息, 来使得后面能够有不同的匹配行为.

语法为: (?(<group_id>/<group_name>)<yes-pattern>|<no-pattern>) , 前面指定分组编号或者名字, 如果分组存在, 则使用 <yes-patterm> 进行匹配, 否则使用 <no-pattern> 进行匹配.

比如这个例子, ^(<)?(\w+)(?(1)>|$)$, 可以匹配到字符串 <aaa>aaa, 也就是< 开头的必须由 > 结尾.

注释

语法 (?#这部分是注释)

匹配规则

下面介绍下几种匹配规则:

  • 贪婪模式: 尽可能多的匹配. 是正则匹配时的默认模式
  • 懒惰模式: 尽可能少的匹配. 通过在量词后添加?指定. 如a*? 就是a*的懒惰版本
  • 独占模式: 在匹配过程中不进行回溯. 通过在量词后添加+指定. 如a++就是a+的独占版本

简单对着几种规则进行说明

贪婪模式

贪婪模式其实就是我们平常最进场使用的模式. 其原则是向后查找尽量长的字符串进行匹配.

比如使用正则b*来匹配字符串abbbc, 能够匹配到如下内容:

位置(前闭后开) 匹配内容
0-0
1-4 bbb
4-4
5-5

匹配过程大致如下:

  1. 0-0: 匹配第一个字符, 发现不是 a, 输出空
  2. 1-4: 一直匹配到字母c发现匹配不上了, 输出bbb
  3. 剩下的同理

懒惰模式

匹配过程与贪婪模式相似, 区别是只要有能够匹配到的, 就输出, 会输出符合规则的最短子串.

还用上面相同的例子举例, 使用正则b*?匹配字符串abbbc, 能够匹配到如下内容:

位置(前闭后开) 匹配内容
0-0
1-1
1-2 b
2-2
2-3 b
3-3
3-4 b
4-4
5-5

与上面的一对比, 区别是不是就很明显了? 这次匹配到的是没单个b字符, 就连每个b字符左面的空字符都匹配上了.

匹配过程就不再赘述了. 这里用一个更加明显且常用的场景来说明贪婪模式懒惰模式的区别, 当我们匹配字符串"hi mom" and "hi son"时:

  • ".*" : 贪婪模式下, 匹配到的是字符串"hi mom" and "hi son"
  • ".*?": 懒惰模式下, 则会分别匹配到字符串"hi mom""hi son"

独占模式

要说明独占模式, 得先简单说一下正则匹配的回溯现象.

比如, 使用正则 ab+bc 匹配字符串 abbbc的时候, 在贪婪模式下(下方的括号为了标明当前匹配的位置):

正则匹配位置 字符串匹配位置 说明
(a)b+bc (a)bbbc 字符 a 完成匹配, 继续下一个匹配
a(b+)bc a(b)bbc 此时, 要匹配字符 b 的数量为 >= 1, 因此会继续向后匹配
正则位置不变, 字符串匹配后移(后续相同操作忽略)
a(b+)bc abbb(c) 当匹配到这个位置的时候, 发现已经匹配不上了. 则字符串会向前移动, 以继续完成匹配.
ab+(b)c abb(b)c 此时, 字符串将已经匹配过的字符又吐出来了. 这个过程就被称为回溯
后续完成匹配, 不再赘述

如果正则是ab+bbc的话, 匹配相同的字符串甚至会发生多次回溯. (懒惰模式也存在回溯现象, 不再赘述)

而回溯现象是及其影响性能的. 而独占模式则是将回溯直接关掉, 匹配性能更好, 但是对正则书写的要求也更高些.

比如匹配abbbc字符串时, 开启独占模式的匹配规则ab++bc会匹配不到任何数据.

ab++c则是能够匹配到字符串abbbc的, 因为匹配过程无需回溯,

这里说句题外话, 在有些编程语言中不支持回溯模式, 比如Go, Python中使用也需要通过regex库.

回溯现象真实场景

你以为回溯现象造成的性能微乎其微么? 不, 有这样一篇文章一个由正则表达式引发的血案, 讲述了因为正则回溯而导致的 CPU 爆满, 感兴趣的去原文看一下.

简单来说, 就是正则表达式 ^([a-z]|[a-z])+$ 在进行匹配的时候,因为a-z在前后组合中重复出现了, 导致大量回溯后的重复判断 . 如果所有的字符都能够成功匹配到前一个组合, 问题还不大, 但一旦有一个字符不匹配就会发生回溯, 且不匹配的字符越靠后, 回溯的层级越深. 查看回溯匹配

对于这种情况如何修改呢?

  1. 将重复匹配去掉, 改为 ^[a-z]+$
  2. 使用独占模式防止回溯. ^([a-z]|[a-z])++$

回溯的介绍, 也可参考此文章

❌