Kerberos 认证体系引起的产品系统免密问题

问题概述 客户对产品系统进行大版本升级,使用升级后的运维工具启动产品系统正常,随后的某项业务产生了数据文件,但是通过 scp 将数据文件拷贝到远程服务器时失败。客户改用升级前的运维工具启动产品系统,业务正常执行,文件成功拷贝到远程服务器。 问题排查 升级改动 在产品系统升级改造前,运维工具使用 Linux 管理类用户——通常是 root 用户——ssh 到服务器,执行启停脚本,该启动脚本切换到产品系统不同组件使用的不同 Linux 用户,执行各自的启停脚本。 为了控制权限,保障产品系统和服务器的安全,这次大版本改造,运维工具使用产品系统不同组件所需的不同 Linux 用户,分别 ssh 到服务器,直接执行各自的启停脚本。 执行失败的业务不在此次升级改造范围中。 首次排查 与客户沟通,除了测试过已经提到的业务,还测试了另一个与 scp 相关的业务,也遭遇了失败。 在产品系统的标准部署方案中,发起相关业务的组件程序所属的 Linux 用户,与远程服务器接收数据文件的用户,通过 ssh 密钥的形式配置了免密登录,以实现 scp 的无障碍使用。一般在这两个业务产生相关报错时,标准流程是建议客户检查组件程序的 Linux 用户与远程服务器用户的免密关系是否正常。本次排查一开始也按照同样的思路进行处理,客户使用终端 ssh 到相关用户进行免密测试时发现一切正常,但是执行业务还是存在问题。 客户自行使用组件所属 Linux 用户,手动执行了相关组件的启停脚本,重新启动了组件程序。然后再次测试业务时正常。此时意识到,通过运维工具启动的产品系统组件进程中,可能有某些环境变量存在问题。客户进一步使用改造前的运维工具重新启停了产品系统,最终测试业务正常执行。 在排查过程中,最需要确认的是,业务执行过程中具体的报错内容是什么?从产品系统的报错中,只能获知 scp 这一步产生问题,但是 scp 的具体报错内容并没有输出。scp 在一个业务脚本中执行,组件直接执行该业务脚本。 先拿到具体的日志。在不改动程序的代码的情况下,通过调整启动脚本和业务脚本,将 scp 产生的报错日志重定向到本地日志文件中。查看日志文件,看到了明确的认证失败的报错。 使用组件用户测试免密关系、直接运行业务脚本,这两项测试行为都是正常执行的,而组件进程调用时有问题。二者之间存在什么差异?在不信邪的情况下,直接查看 ssh 免密相关的密钥,发现根本没有配置,这是才得知客户的免密方式是 Kerberos。 更进一步,直接修改业务脚本,在其中插入 ssh -vvv,来仔细查看究竟碰到了什么认证问题。仔细排查 ssh 的 debug 日志发现,本次 ssh 连接产生了三次认证请求,分别是 Kerberos 认证、密钥认证和密码认证。密钥认证和密码认证都没有配置,重点在于 Kerberos 认证,认证失败报错信息中提示找不到一个位于 /tmp 目录下的缓存文件,文件名形如 /tmp/krb5cc_<uid>_%randomString。客户的技术专家告知,这个缓存文件是 Kerberos 的认证票据。 Kerberos Kerberos 是一种计算机网络授权协议,用于通信各方进行身份认证。简单来说,就是客户端与服务端在可信的第三方 Kerberos 认证服务器上注册,当客户端与服务端通信时,依据各自的身份认证票据和第三方 Kerberos 认证服务器,来证明各自的身份,完成身份认证。 客户端和服务器都可以视作 Kerberos 服务的客户端,第三方 Kerberos 认证服务器简称为 KDC。当客户端向 KDC 认证时,KDC 会为该会话发送一组特定于该会话的票据返回到客户端。这个票据在整个认证流程中非常重要。 二次排查 基于 Kerberos 的票据概念,怀疑运维工具启动的组件进程与客户手动启动的组件进程之间存在差异。比如客户是通过正常流程登录,拥有相关票据,并在启动组件时继承给相关进程;而运维工具通过 ssh 登录时无法获取票据。 此时突然意识到,程序组件在启动时,也会通过 scp 获取远程服务器的数据文件,如果这一步失败,则产品系统的启动是失败的,运维工具会产生启动失败提示。而客户的产品系统启动是正常的,意味着启动时的 scp 是没有问题的。这一步是如何完成认证的呢? 既然怀疑到了进程的环境变量,那不妨看一看。使用升级前和升级后的运维工具分别启动产品系统,通过 strings /proc/<pid>/environ 查看进程持有哪些环境变量并对比。发现有问题的进程的环境变量中,出现了一个 KRB5CCNAME=FILE:/tmp/krb5cc_<uid>_%randomString。 通过 man Kerberos,了解到这个环境变量指向票据的缓存文件。服务器中确实没有这个缓存文件,但是存在 /tmp/krb5cc_<uid> 形式的文件,这应该是票据缓存文件。客户的技术专家明确了这个就是票据缓存,那带有随机字符串后缀的票据缓存的生存周期,是不是与 ssh 会话关联呢? 运维工具通过 ssh 密码登录到服务器,运行对应的启动脚本后退出。一个猜测是,当运维工具 ssh 到服务器时,这种随机字符串的票据缓存是生成了的,成功启动了组件后 ssh 会话结束,对应的票据缓存也被清理,导致启动的组件进程找不到这些票据缓存,致使 ssh 认证失败。之前提到组件启动时也会进行 scp 且成功,观察启动脚本中,在拉起组件后 sleep 了一段时间,这段时间 ssh 会话依旧存活,票据缓存文件存在,组件进程完全可以在这段时间内完成 scp 操作;sleep 结束后 ssh 会话退出,票据缓存文件失效,组件进程执行后续的业务失败。 那么为什么运维工具发起的 ssh 会话会产生这种随机字符串的票据缓存?终端登录的用户的环境变量中没有指定 KRBCCNAME,所以按照默认逻辑找到了有效的 /tmp/krb5cc_<uid> 票据缓存文件。票据的处理逻辑是怎样的?为什么终端登录的用户没有指定环境变量呢? 运维工具发起的 ssh 会话是通过密码认证的,客户的技术专家告知,终端登录的 ssh 会话是通过了跳板机的认证后,由跳板机与服务器之间通过密钥完成 ssh 认证的。那么可以提出一个假设:Kerberos 认为密码认证的 ssh 会话不是完全可信的,于是给了基于 ssh 会话生存周期的票据缓存;而通过密钥认证的 ssh 会话可信,完全可以使用默认的 /tmp/krb5cc_<uid> 的票据缓存。 查找资料 基于 Kerberos、ssh、密码认证、KRB5CCNAME 等关键词,找到了 pam_krb5 这个模块。这个模块基于 Linux 的 PAM 机制,允许用户使用密码登录时,产生基于会话的票据缓存文件。 这下见到胜利的曙光了。了解这个模块的相关信息和配置方式,在客户环境上查看是否启用了这个模块,确定上述提到的问题是由这个模块产生的。 如何解决 既然一切问题来源于 KRB5CCNAME 指向特殊的票据缓存文件,那应该怎么处理呢? 用户登录时不产生这个环境变量; 用户登录后清理掉这个环境变量。 对于第一种方案,既然通过密钥认证的 ssh 会话不会产生 KRB5CCNAME,那么客户可以使用密钥来登录。运维工具的配置中提供了 ssh 密码认证和密钥认证,客户只需要调整运维工具的使用方式即可。这个方法暂时没有论证,但是可行度很高。 对于第二种方案,在用户的 profile 中配置 unset KRB5CCNAME,使得用户登录后清理掉这个环境变量,来通过默认逻辑使用 /tmp/krb5cc_<uid>,找到正常的票据缓存文件。通过现场测试,这个方案可行。 然而并不是说提出了两个方案,就可以随便选择一个的。方案之间也需要进行评审。 对于第一种方案,部署一套产品系统会使用多台服务器,这些服务器都需要配置密钥,并且在运维工具上配置。而对于同一套产品系统,可能有多份运维工具要连接,那么这些运维工具之间不应该使用同一套密钥,而是分别配置,才能保障安全。多份运维工具对应一套产品系统环境就要配置很多密钥,而客户部署有多套产品系统环境,这对于客户的运维来说压力很大,客户不一定会接受这个方案。 那采用第二种方案可以吗?笔者在测试中发现,一般环境中不会配置 pam_krb5 模块;客户作为集团业务部门,服务器环境都是集团统一的,由 IT 部门和安全部门进行配置的。由此可以推测,这种安全相关的模块应该是集团安全部门的要求。如果直接 unset 掉环境变量,那么这个模块的安全意义也消失了,集团安全部门不一定会接受。这就是客户作为集团业务部门和安全部门之间的交涉了。 所以提出了各种解决方案,也需要产品系统提供方和客户进行会议讨论,找到双方乃至第三方——集团安全部门——都能接受的方案。 7 WHY 为什么业务执行失败?因为业务中 scp 认证失败。 为什么 scp 认证失败?因为 scp 使用 Kerberos 认证失败。 为什么 Kerberos 认证失败?因为所需的票据缓存文件不存在。 为什么票据缓存文件不存在?因为进程所需的票据缓存文件的生存周期与 ssh 会话挂钩。 为什么会挂钩?因为 ssh 会话指定了 KRB5CCNAME 环境变量。 为什么会产生这个环境变量?因为 ssh 认证体系中引入了 pam_krb5 模块。 为什么要引入这个模块?因为客户的安全部门对于 ssh 安全的要求。

2025/3/24
articleCard.readMore

通过 strace 追踪 sqlldr 的性能问题

写在前面 Sqlldr 是 Oracle 用于将数据从外部文件直接导入到 Oracle 数据库表的工具。 笔者负责的公司项目中,某一个业务需要通过 sqlldr 导入外系统给出的数据。在日常测试和客户反馈中,碰到了 sqlldr 导入数据缓慢的问题。经过排查,处理了两个原因导致的性能问题。 在排查过程中,使用的工具是系统调用追踪工具 strace,本文将记录两次排查过程。 网络导致的问题 公司测试环境 第一次碰到 sqlldr 加载数据慢的问题,是测试同事反馈的。当时测试同事反馈该业务执行非常缓慢,在查看了业务日志后,发现时间卡在了 sqlldr 导入数据的阶段。业务中有将近一百五十张表,每张表的导入时间以分钟计时。 排查时,首先查看了 sqlldr 的导入日志,发现实际的数据导入时间都是秒级的,与日志显示的分钟级不符。于是手动执行了 sqlldr 相关的导入命令,将一张表的数据手动导入。通过 time 命令计时和肉眼观察,发现虽然整个 sqlldr 命令完成耗时一分钟多,但是耗时都是在连接数据库的阶段,一旦连接成功,数据在一秒内就可以完成导入。 于是将目光放在了网络相关的问题。一开始还不知道应当如何排查网络问题,但是突然想到可以使用 strace 追踪系统调用,看一看耗时都在哪里。于是通过以下命令抓取了追踪日志。 1 strace -T -tt -f -o strace.log sqlldr database control=table.CTL 这条命令将追踪 sqlldr 执行过程中,父进程及其子进程的每个系统调用的耗时,并打印到日志文件中。并通过以下命令将其中耗时大于 0.01s 的系统调用都打印出来。 1 grep -E '<0\.0[2-9][0-9]{4}>|<0\.[1-9][0-9]{5}>|<[1-9][0-9]*\.[0-9]{6}>' strace.log 由于当时排查时的日志没有保留,且后续没能构建同样的问题环境,就不拿出具体的日志了。 总之,日志显示,耗时大头都在一些读写相关的系统调用上,向上追踪文件描述符,发现这是一个 socket 链接,其 connect 的 IP 地址指向了公司的 AD 域服务器。与 IT 同事联系,发现这台服务器未启动。启动后,业务就正常运行,数据导入时间恢复预期。 客户生产环境 后来某家客户联系到开发组,反馈了该业务执行缓慢的问题,开发组同事查看日志后发现也是 sqlldr 导入数据慢的问题。笔者在听说了这个事情后,立刻联想到了当初排查公司测试环境的事情。于是主动介入排查,在执行了相同的排查流程后,得到追踪日志。 拿到追踪日志分析时,发现同样是读写系统调用的耗时问题,向上追踪文件描述符,发现socket链接 connect 的 IP 地址是 8.8.8.8,立刻反应过来这是 Google 的 DNS 服务。查看了客户服务器的 DNS 配置,配置文件中的确配置的是此 IP。 于是拿着追踪日志与客户沟通,向对方解释了 sqlldr 导入数据缓慢的原因,并建议对方配置国内云服务商的 DNS 服务器。客户在修改配置后,重新执行业务,发现执行时间恢复正常。 sqlldr bug 导致的问题 在后来的开发自测过程中,笔者发现在开发服务器上测试该业务时,也有导入数据缓慢的问题,基于经验怀疑是网络问题。 测试环境上数据量较小,正常情况下每张表的导入时间不会超过 500 毫秒。但是查看业务日志时发现,每张表的导入时间都在 1 秒左右。执行了排查流程后,发现并不是网络问题,而是另一个系统调用耗时。 1 2 3 32176 18:01:51.382432 nanosleep({tv_sec=1, tv_nsec=0}, <unfinished ...> ...... 32176 18:01:52.382649 <... nanosleep resumed>NULL) = 0 <1.000197> 可以看到,进程32176调用了 nanosleep(),传入参数为 1秒。而这个进程也是由加载了sqlldr的进程 clone() 出来的,且这个进程的功能似乎很简单,就是单纯的 sleep。 1 2 3 4 5 6 32176 18:01:51.382305 set_robust_list(0x7fbc34a989a0, 24) = 0 <0.000072> 32176 18:01:51.382432 nanosleep({tv_sec=1, tv_nsec=0}, <unfinished ...> 32176 18:01:52.382649 <... nanosleep resumed>NULL) = 0 <1.000197> 32176 18:01:52.382716 madvise(0x7fbc34a78000, 65536, MADV_DONTNEED) = 0 <0.000012> 32176 18:01:52.382758 exit(0) = ? 32176 18:01:52.382850 +++ exited with 0 +++ 很奇怪的是,这个问题只在 Oracle database 19.3.0.0.0 的 database 和 sqlldr 上才有问题。笔者另一台装有 Oracle database 12.2.0.1.0 的环境,使用 sqlldr 就没有这个问题。且只有 19.3 的 sqlldr 向 19.3 的 database 中导入数据才有问题,两种版本交叉使用则没有问题。 当时笔者排查到这里后,没有思路,且 Google 也没有线索,于是暂时搁置下来。 前几天开发组的同事又碰到客户反馈这个业务的执行耗时问题,笔者提醒了同事可能是网络问题,同事也想起当时的排查。于是在客户环境上执行排查流程拿到追踪日志后,发现是 nanosleep() 的耗时问题,笔者立刻想到了公司测试环境上碰到过的事情,于是和同事沟通了这个问题,也和同事在公司测试环境上进行了复现。 这位同事对 Oracle database 的研究颇为深入,在听说笔者反馈的版本问题后,立刻着手下载了不同版本的 sqlldr,最终发现 19.14.0.0.0 的 sqlldr 在导入数据时没有问题。这位同事也很有钻研精神,在 Oracle support 上检索了一天,最终找到了相关的 Bug 记录:「Bug 30571037 : 19C SQL*PLUS TAKES 1 SECOND WHEN EXITING AN APPLICATION」。 同样是 nanosleep() 的耗时问题,同样是 1 秒。在 Google 这个 Bug 后,找到了这篇文档,里面提到了一个参数导致的客户端退出缓慢的问题,并指出可以关闭这个参数来规避这个问题。 在客户环境上关闭这个参数后,sqlldr 导入数据缓慢的问题得到了解决。 写在最后 实际上,笔者对于 Oracle database 并没有太多了解,更多的是写业务 SQL。在第一次碰到 sqlldr 的问题时,没有排查思路,但是靠着对 Linux 的使用和一些了解,想到了使用 strace 来追踪系统调用耗时,最终找到了网络相关的问题;也正是因为这个经历,在后续排查中也排上用场,找到了 nanosleep() 的耗时问题,为同事提供了关键词,最终找到 Oracle 的相关 Bug 报告。 看来还是要多学习 Linux 相关开发和性能测试工具,也可以更多地学习想要学习的 kernel 相关的知识。

2024/7/31
articleCard.readMore

控糖革命

写在前面 本文是阅读《控糖革命》1时记录的一些内容,如有不当之处感谢指正。 血糖的峰值与谷值 葡萄糖是身体能量的主要来源。我们通过所吃的食物获得大部分葡萄糖,葡萄糖通过血液进入细胞。 血液中葡萄糖的浓度在一天中有很大的波动,而浓度的急剧增加(称为血糖峰值)会影响我们的方方面面,包括心情、睡眠、体重、皮肤、免疫系统的健康,患心脏病的风险以及女性受孕的机会。血糖谷值通常导致嗜睡。 如果想感觉良好,就必须避免血糖出现大的峰值或者谷值,让自己的血糖曲线变得平稳。这也是控糖的核心目的。 为什么要平稳血糖曲线 当血糖处于波峰且峰值过高时,高血糖会增加氧化应激的程度,也会促进糖化反应。 高血糖也意味着高胰岛素,胰岛素水平和血糖水平成正相关。胰岛素促进葡萄糖在肝脏和肌肉中转化为糖原并储存起来。当肝脏和肌肉中存满糖原,剩余的葡萄糖会转化成脂肪,并储备起来。果糖无法转化成糖原,只能转化为脂肪。 肝脏和肌肉能存储的糖原是有限的,高胰岛素始终在促进脂肪的合成,那还如何消耗脂肪?只有当低胰岛素,且糖原消耗殆尽时,才能开始消耗脂肪。 平稳血糖曲线,可以适当降低胰岛素水平。 如何平稳血糖曲线 核心在于一餐的进食顺序:先吃纤维、然后吃蛋白质和脂肪,最后吃淀粉和糖类。 葡萄糖来源于淀粉和糖类。在进食过程中,淀粉首先在口腔中与唾液淀粉酶接触产生麦芽糖和葡萄糖;糖类则被嚼碎形成小块,增加与消化酶的接触面积。胃不是淀粉和糖类的分解场所,接下来的小肠才是重点。 小肠中的胰液含有胰淀粉酶,进一步将淀粉分解为麦芽糖,随后,小肠粘膜分泌的麦芽糖酶将麦芽糖分解为葡萄糖。 双糖(如蔗糖、乳糖等)在小肠中被相应的酶(蔗糖酶、乳糖酶等)分解为单糖(葡萄糖、果糖、半乳糖等),然后被小肠绒毛吸收进入血液。 淀粉和糖类最终生成的葡萄糖进入血液,造成血糖水平快速升高,吃的碳水化合物越多,吃得越快,就会出现越大的葡萄糖负载,从而导致越高的血糖峰值。 如果先吃纤维,纤维会首先从胃进入小肠,再到被排出,这个过程十分缓慢,且形态不变(纤维不会被消化系统分解为葡萄糖)。也就是说,纤维可以缓解任何在其之后进入消化系统的食物被分解和吸收的速度,平缓血糖曲线。 所以先吃纤维,最后吃淀粉和糖类。 如何摄取纤维 纤维对我们的身体起着至关重要的作用:为肠道中的有益菌提供能量,丰富肠道菌群,降低胆固醇水平,确保一切生理活动顺利进行。 获取纤维的最佳方式究竟是什么呢?答案是绿色蔬菜。绿色蔬菜含有大量的纤维,并且只含少量淀粉。 脂肪也要吃 身体也需要摄入脂肪,有一些脂肪要比另一些脂肪对身体更好。 脂肪分为饱和脂肪和不饱和脂肪2。不饱和脂肪又分为单不饱和脂肪、多不饱和脂肪和反式脂肪。从动物获得的食物通常含有饱和脂肪,这些脂肪在室温下是固态的;从植物中获得的脂肪通常含有单不饱和脂肪或者多不饱和脂肪,这些脂肪酸在室温下多数是液态的;反式脂肪是人工合成的。 好的脂肪以饱和脂肪酸(来自动物、黄油、酥油和椰子油)或单不饱和脂肪酸(来自水果和坚果,如牛油果、夏威夷果和橄榄果)为主要成分。 不好的脂肪会给我们造成刺激,危害心脏健康,使我们的内脏脂肪增加,并使胰岛素抵抗综合征的病情恶化。它们以多不饱和脂肪酸或反式脂肪酸为主要成分,存在于如大豆油、玉米油、菜籽油、米糠油、油炸食品和快餐中。 《控糖革命》1 《控糖革命》中指出好的脂肪包括饱和脂肪,不好的脂肪包括多不饱和脂肪。但是《默沙东诊疗手册》指出: 建议剔除饮食中的反式脂肪。如果可能的话,应该用单不饱和脂肪和多不饱和脂肪(包括 ω-3 脂肪)替代饱和脂肪和反式脂肪。 《默沙东诊疗手册》3 且搜索引擎提供的相关文章也建议减少饱和脂肪的摄入。在没有系统学习相关文献的情况下,以《默沙东诊疗手册》的建议为准。 警惕一些糖 不管是什么糖,源自什么植物,最终都会分解为葡萄糖和果糖。注意果糖只能被转化为脂肪。 水果中含有纤维和糖类,水果其实是可以吃的。但是少喝果汁,纤维不耐保存,果汁会剔除纤维并增加糖类。 1.《控糖革命》 ↩ 2.Wiki ↩ 3.《默沙东诊疗手册》 ↩

2024/5/29
articleCard.readMore

单例模式的几种 C++ 实现

写在前面 在开发中,有时候会需要一些类在项目的生命周期中只有一个实例。这样的设计被称作单例模式。 本文用于记录项目开发过程中,设计日志管理器时用到的单例模式,以及开发过程中的一些学习和思考。 饿汉与懒汉 单例模式中有饿汉和懒汉两种模式,饿汉模式指在程序启动时即进行单例的构造;懒汉模式为懒加载,只有第一次调用时才会进行单例的构造。 在多线程开发中,要求对象的 this 指针在构造期间不被泄漏。饿汉模式只要选择合适的单例构造时间,即可保证;但是懒汉模式可能会在多线程并发调用时,触发线程安全问题1。 本文主要关注懒汉模式下单例构造时的多线程安全问题。 最简单的版本 在设计单例模式时,考虑到生命周期中只有一个实例,那么类的构造函数应当是不应该被使用者随意调用的,但是又需要生成单例使用。基于这个要求,可以得到最基本的两点: 类的构造函数应当是 private 的。 类应该提供一个静态函数用于获取单例。 基于以上要求,先写一个简单版本。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 // log_manager.h class LogManager { public: static LogManager* getInstance(); LogManager(const LogManager&) = delete; LogManager(LogManager&&) = delete; LogManager& operator=(const LogManager&) = delete; LogManager& operator=(LogManager&&) = delete; bool writeLog(Buffer& logs); ~LogManager() { // clean up } private: explicit LogManager() {} private: static LogManager* m_log_manager_; }; // log_manager.cpp LogManager* LogManager::m_log_manager_ = nullptr; LogManager* LogManager::getInstance() { if (m_log_manager_ == nullptr) { m_log_manager_ = new LogManager(); } return m_log_manager_; } delete 掉了拷贝构造函数、拷贝复制操作符、移动构造函数和移动复制操作符。在getInstance() 中判断指向实例的指针 m_log_manager_ 的状态,来决定是否需要构造实例。 这个版本足够简单,也可以使用(但是不正常)。这样一个单例,在多线程并发的情况下,是否会触发问题呢? 考虑并发 在并发状态下,如果有多个线程同时使用 getInstance(),可能这多个线程都能通过指针是否为空的检测,然后去创建实例。这样的话,不仅与单例的初衷背道而驰,这些创建出来的实例也没有被保存,造成了内存泄漏。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // log_manager.cpp LogManager* LogManager::m_log_manager_ = nullptr; std::mutex LogManager::m_mutex_; LogManager* LogManager::getInstance() { std::lock_guard lock(m_mutex_); // (1) if (m_log_manager_ == nullptr) { m_log_manager_ = new LogManager(); } return m_log_manager_; } easy,加一个锁即可解决的事情。但是很明显,(1) 这个锁加的不是很合适,如果单例已经存在,每次调用时却上锁,锁了个寂寞,还浪费了资源。 1 2 3 4 5 6 7 8 9 10 11 12 13 LogManager* LogManager::getInstance() { if (m_log_manager_ == nullptr) { std::lock_guard lock(m_mutex_); // (1) if (m_log_manager_ == nullptr) // (2) { m_log_manager_ = new LogManager(); // (3) } } return m_log_manager_; } 现在把锁放到里面(1),那么在单例已经存在的情况下,就无需上锁。 如果多个线程同时通过外层的检测,那么是否会如一开始的版本一样,创建多个实例呢?通过 (2) 的检测,就可以杜绝这个情况。 new 的原子性问题 当 new 一个实例时,大致做了什么? 分配一块大小合适的内存。 在该内存上调用类的构造函数。 将内存地址写回变量。 可以看到,这是一个由很多指令构成的操作。但是这个顺序可能只是我们想象的顺序,实际上从编译器过一遍,在 CPU 上 2 和 3 的顺序是不一定的。如果先执行 3,然后在执行 2 前,另一个线程调用该方法,进来以后发现指针上有值了,尽管指向的是一块未经初始化的空白内存,但是该线程依然会直接使用这个指针。 而在实例构造期间,就取得该实例的 this 指针,违反了线程安全1。 因此可以通过原子操作,避免此处的问题。可见以下代码2。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 std::atomic<LogManager*> LogManager::m_log_manager_ = nullptr; std::mutex LogManager::m_mutex_; LogManager* LogManager::getInstance() { auto tmp = m_log_manager_.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire); if (tmp == nullptr) { std::lock_guard<std::mutex> lock(m_mutex_); tmp = m_log_manager_.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new LogManager(); std::atomic_thread_fence(std::memory_order_release); m_log_manager_.store(tmp, std::memory_order_relaxed); } } return tmp; } std::call_once 以原子操作做处理,比较麻烦。《C++ 并发编程实战》3中提出了另外两种方式。 一种是通过 std::call_once 4 实现,。这个函数准确执行一次可调用对象,即使同时从多线程中调用。 1 2 3 4 5 6 7 8 9 std::atomic<LogManager*> LogManager::m_log_manager_ = nullptr; std::once_flag resource_flag; // 1 LogManager* LogManager::getInstance() { std::call_once(resource_flag,initInstance); // 定义 init_instance() 用于初始化 return m_log_manager; } 第二种方式提出,使用静态局部变量可能更加高效5。 静态局部变量 cppreference 和 《C++ 并发编程实战》都更倾向于在 getInstance() 中通过静态局部变量来实现单例构造。 在之前翻阅文档时,也提到了这种方式。但是当时考虑到静态局部变量在构造时会不会有多线程访问的安全问题,于是没有考虑此方式。但是后续看书时提到,自 C++11 起,多线程同时初始化同一静态局部变量,标准要求初始化严格发生一次6。编译器的相关实现也多是依靠双重检查锁。 由编译器实现双重检查锁总好过自己实现。 1 2 3 4 5 6 LogManager* LogManager::getInstance() { static LomManager m_log_manager{}; return &m_log_manager; } 单例模式的析构 考虑到上文中提到的,除去静态局部变量的方式,其他方式都是 new 出一个实例,那么在使用过程中就要考虑到 delete 的问题。 最简单的方式是提供一个 static void destroyInstance() 在程序结束时调用。但既然是懒汉模式,自然也希望析构也无需关心。 出于这个目的,考虑是否可以通过智能指针管理单例的构造和析构。但是构造函数和析构函数都被设置为 private,智能指针就无法指定用于析构的函数。 构造函数设置为private,是为了单例模式;析构函数设置为private,是为了防止使用者显式析构。 静态局部变量则可以在程序结束时自动析构。 1.《Linux 多线程服务端编程》1.2 节 ↩ 2.代码来源于李建忠的《C++ 设计模式》 ↩ 3.《C++ 并发编程实战》3.3.1 节 ↩ 4.std::call_once ↩ 5.std::call_once 注解 ↩ 6.静态局部变量 ↩

2024/3/26
articleCard.readMore

通过 ChatGPT 协助解决软件安装问题

问题描述 近日在刷帖子时,发现 WSL2 史诗级更新,出于好奇便对 WSL 进行了升级,来到了 WSL 2.0.3.0。但是新特性只支持 Windows 11 23H2,现在的系统是 Windows 10 22H2,所以白忙活一场。后来在通过 pacman 安装软件包时,发现以下报错,猜测可能是一些配置文件被破坏。 1 2 3 4 5 6 7 8 ... :: Running post-transaction hooks... (1/3) Reloading system manager configuration... Skipped: Current root is not booted. (2/3) Creating temporary files... /usr/lib/tmpfiles.d/journal-nocow.conf:26: Failed to resolve specifier: uninitialized /etc/ detected, skipping. All rules containing unresolvable specifiers will be skipped. (3/3) Arming ConditionNeedsUpdate... 尝试卸载了 WSL 更新,回退到 WSL 1.2.5.0,但是问题依旧存在。 初步尝试 根据 ChatGPT 对于 Skipped: Current root is not booted. 的描述,这个问题可能是引导配置或者文件系统分区问题。但是一番操作下来没有找到有效的突破口,网络上的资源中也没有找到有效信息。 突发奇想 是否可以利用 ChatGPT 了解 pacman 安装软件包的过程中,在 post-transaction hooks 的过程中都做了一些什么?了解这些过程中的操作,然后对症下药,是否可行呢? 于是询问 ChatGPT 并得到如下回复。 Q: Arch Linux 的 pacman 在安装软件的过程中,Running post-transaction hooks,这一步是在干什么? A: Arch Linux 中的 Pacman 在安装软件包之后会运行一系列的 post-transaction 钩子(post-transaction hooks)。这些钩子是一种机制,允许用户或软件包维护者执行特定的操作或配置更改,以确保软件包的正确安装和系统的一致性。 …… 您可以在 /usr/share/libalpm/hooks 目录中找到与 Pacman 默认提供的一些 post-transaction 钩子相关的脚本。您还可以创建自己的自定义钩子来执行特定任务,然后将它们添加到 /etc/pacman.d/hooks 目录中。这些自定义钩子将在 Pacman 的软件包事务中运行。 开始修复 首先可以看到这里给出了一个目录 /usr/share/libalpm/hooks,可以看看这些目录中有什么。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ❯ cd /usr/share/libalpm/hooks ❯ ll .rw-r--r-- root root 233 B Wed Sep 27 21:11:01 2023 20-systemd-sysusers.hook .rw-r--r-- root root 226 B Wed Sep 27 21:11:01 2023 30-systemd-binfmt.hook .rw-r--r-- root root 254 B Wed Sep 27 21:11:01 2023 30-systemd-catalog.hook .rw-r--r-- root root 265 B Wed Sep 27 21:11:01 2023 30-systemd-daemon-reload.hook .rw-r--r-- root root 246 B Wed Sep 27 21:11:01 2023 30-systemd-hwdb.hook .rw-r--r-- root root 231 B Wed Sep 27 21:11:01 2023 30-systemd-sysctl.hook .rw-r--r-- root root 228 B Wed Sep 27 21:11:01 2023 30-systemd-tmpfiles.hook .rw-r--r-- root root 261 B Wed Sep 27 21:11:01 2023 30-systemd-udev-reload.hook .rw-r--r-- root root 227 B Wed Sep 27 21:11:01 2023 30-systemd-update.hook .rw-r--r-- root root 243 B Thu Jan 1 08:00:00 1970 40-update-ca-trust.hook .rw-r--r-- root root 273 B Sat Sep 30 04:52:41 2023 60-depmod.hook .rw-r--r-- root root 355 B Sat Sep 2 21:42:07 2023 dbus-reload.hook .rw-r--r-- root root 224 B Tue Jul 25 06:17:30 2023 detect-old-perl-modules.hook .rw-r--r-- root root 245 B Tue Sep 12 04:40:39 2023 gio-querymodules.hook .rw-r--r-- root root 338 B Tue Sep 12 04:40:39 2023 glib-compile-schemas.hook .rw-r--r-- root root 281 B Mon Mar 27 07:16:05 2023 texinfo-install.hook .rw-r--r-- root root 285 B Mon Mar 27 07:16:05 2023 texinfo-remove.hook .rw-r--r-- root root 271 B Tue Oct 3 21:26:46 2023 vimdoc.hook 在这些 hook 文件中搜索 system manager configuration,可以找到文件 30-systemd-daemon-reload.hook,来看一看这个文件中的内容。 1 2 3 4 5 6 7 8 9 10 11 [Trigger] Type = Path Operation = Install Operation = Upgrade Operation = Remove Target = usr/lib/systemd/system/* [Action] Description = Reloading system manager configuration... When = PostTransaction Exec = /usr/share/libalpm/scripts/systemd-hook daemon-reload 对这个 hook 文件,猜测一下大概的作用,应该就是 install | upgrade | remove 时以 daemon-reload 作为参数调用脚本 /usr/share/libalpm/scripts/systemd-hook。但是这些都不是重点,重点关注这个脚本的内容。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #!/bin/sh -e is_chrooted() { if systemd-detect-virt --chroot; then echo >&2 " Skipped: Running in chroot." exit 0 fi } systemd_live() { is_chrooted if [ ! -d /run/systemd/system ]; then echo >&2 " Skipped: Current root is not booted." exit 0 fi } udevd_live() { is_chrooted if [ ! -S /run/udev/control ]; then echo >&2 " Skipped: Device manager is not running." exit 0 fi } ...... 可以看到这个脚本设置了几个校验函数,重点在于 systemd_live(),报错信息中有 pacman 更新时遇到的内容。这个函数校验了目录 /run/systemd/system 是否存在。测试发现当前系统中确实不存在。 只需要简单的创建这个目录吗?目录的属主和权限应该是怎样的呢?这些问题都没有答案,没事,可以问一问 ChatGPT。 Q: Arch Linux 的目录 /run/systemd/system 的作用是什么?这个目录的属主和权限是怎样的? A: /run/systemd/system 目录是 Arch Linux 中的一个重要目录,它与 systemd 系统管理守护进程相关。这个目录的作用是存储运行时(runtime)的 systemd 服务单元配置文件,这些配置文件定义了系统上正在运行的各种服务。 以下是有关 /run/systemd/system 目录的一些关键信息和作用: 运行时服务配置:/run/systemd/system 目录包含正在运行的 systemd 服务单元的配置文件。这些配置文件描述了系统上当前活动的服务,以及它们的属性、依赖关系和其他相关信息。 动态生成:与传统的 /etc/systemd/system 目录不同,/run/systemd/system 目录中的配置文件通常是动态生成的。当系统启动时,systemd 根据需要启动各种服务,并在运行时创建相应的服务单元配置文件。这些配置文件存储在 /run/systemd/system 目录中。 临时性质:/run/systemd/system 目录中的配置文件是临时的,它们在系统重新启动时会被清除。这是因为它们是根据当前系统状态和需要生成的,而不是静态的配置文件。重新启动后,新的配置文件将再次根据系统状态生成。 管理正在运行的服务:通过查看和编辑 /run/systemd/system 目录中的配置文件,您可以查看和管理当前正在运行的 systemd 服务。这对于调试和了解系统中的活动服务非常有用。 以下是有关 /run/systemd/system 目录的属主和权限的一般设置: 属主(Owner):通常为 root 用户,这意味着只有超级用户(root)有对该目录的完全控制权,包括创建、修改和删除文件。 权限(Permissions):通常设置为 755,这意味着目录的权限如下: 所有者(Owner):读、写、执行权限 (7) 组(Group):读、执行权限 (5) 其他用户:读、执行权限 (5) 总之,/run/systemd/system 目录在 Arch Linux 中用于存储正在运行的 systemd 服务单元的配置文件,它反映了系统的当前状态和正在执行的服务。这个目录的临时性质使得系统能够根据需要动态管理服务,而不必依赖静态的配置文件。 可以看到这是一个动态的、临时的目录。在我手中其他服务器上看了一下这个目录的属主和权限进行验证,结果如下,结果也相符合。 1 2 service@oracle-service:~$ ll /run/systemd | grep system/ drwxr-xr-x 3 root root 80 Oct 4 23:35 system/ 那么就可以在 WSL 的系统中创建这个目录,并设置相应的属主和权限。但是问题是创建了这个目录后,执行更新依然有问题;更重要的是,重启后这个目录依然不存在。这很好理解,这是一个 systemd 管理的临时目录,那么在重启后会消失并重新创建。现在的问题是没有重新创建。 发帖子去社区求助!但是在写帖子的过程中,我突然想起来 WSL2 一开始并不支持 systemd,哪怕后来微软官方支持了我也没有开启。于是去 /etc/wsl.conf 中配置 systemd 的启用后,重启 WSL 并尝试安装软件包,没有产生报错信息了。 至此应该就算修复完成了? 一些优化 pacman 的 debug 参数 在社区中准备写帖子时翻了一下别人的问题,发现 pacman 可以通过添加参数 --debug --verbose 来获取一些信息。通过这些参数获得了以下输出。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ... :: Running post-transaction hooks... (1/3) Reloading system manager configuration... debug: executing "/usr/share/libalpm/scripts/systemd-hook" under chroot "/" Skipped: Current root is not booted. debug: call to waitpid succeeded (2/3) Creating temporary files... debug: executing "/usr/share/libalpm/scripts/systemd-hook" under chroot "/" /usr/lib/tmpfiles.d/journal-nocow.conf:26: Failed to resolve specifier: uninitialized /etc/ detected, skipping. All rules containing unresolvable specifiers will be skipped. debug: call to waitpid succeeded (3/3) Arming ConditionNeedsUpdate... debug: executing "/usr/share/libalpm/scripts/systemd-hook" under chroot "/" debug: call to waitpid succeeded debug: unregistering database 'local' debug: freeing package cache for repository 'local' debug: unregistering database 'core' debug: freeing package cache for repository 'core' debug: unregistering database 'extra' debug: freeing package cache for repository 'extra' debug: unregistering database 'archlinuxcn' debug: freeing package cache for repository 'archlinuxcn' 可以直接看到执行了脚本 /usr/share/libalpm/scripts/systemd-hook。

2023/10/7
articleCard.readMore

【xv6】Copy on write fork()

fork() 的缺点 在日常的 Linux 编程中,一般会通过 fork() 创建一个新进程,分为父子进程来处理逻辑;或者在子进程中通过 exec() 来加载一个新程序,shell 启动新程序就是这样操作的。在执行 fork() 时,子进程会完全拷贝父进程,包括其执行代码段、数据段、栈和堆,并在接下来的处理过程中修改数据段等内存空间中的数据,而不影响另一个进程。 然而这样的直接拷贝,对于内存来说是一种浪费。执行 fork() 后子进程可能只是简单的执行了一些动作;或者直接调用 exec() 将新程序替换到代码段,重新初始化数据段和堆栈。那 fork() 时的完整拷贝又有什么意义呢? Copy on write 基于以上以及其他缘由,开发者们提出了「Copy on write」机制。 在 COW 机制之前,执行 fork() 后会在 kernel 中初始化子进程的数据结构,并逐页复制父进程的物理页块的内容到自己的页块上,并在页表中将虚拟地址映射到物理地址上。 而 COW 机制不会逐页复制父进程的物理页块,而是直接在页表中将虚拟地址映射到父进程的物理地址上,即父子进程共享同一物理地址。同时将物理页块设置为不可读,防止某一进程修改影响到其他进程。当某一个进程需要修改内存空间时怎么办呢?因为进程尝试写一个不可写的物理页块,会触发 Store page fault,可以通过检查物理页块的标志位,确定它是一个 COW 页块,那么就拷贝其内容到一个新申请的物理页块上,将这个新的物理页块映射到需要修改内存的进程中,供其使用。这样父子进程就不会相互影响。 当多个进程使用同一物理页块时,可以通过维护一个引用计数数组,来确定释放物理页块时是假释放还是真销毁。 这种方式避免了无意义的拷贝工作,而是将真正有『价值』的拷贝工作延后到需要的时候,避免了浪费。 结合 COW 的 fork() 实现 具体实现可见 GitHub。 创建新进程 首先,可以在 kernel/kalloc.c 中维护一个引用计数数组,来确定每个物理页块的引用数,同时也需要引入一个锁来保障原子性。cowcount() 则完成对这个计数数组的操作,当标志位 flag 大于 0 时表示为计数数组 +1,小于 0 时表示 -1,等于 0 时只返回对应地址的引用计数。 在 kernel/risc.h 中定义了用于表示 COW 页块的 PTE 标志位。在 kernel/memlayout.h 中定义了表示引用计数数组长度的 COWCOUNTSZ,和用于计算物理地址对应数组索引的 COWCOUNT(pa),这里的 pa 指物理页块的首地址。 考虑到 kernel/param.h 中通过 NPROC 来定义 xv6 中进程的最大数量,引用计数数组使用 unsigned char 类型即可,节省了内存空间。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // kernel/risc.h #define PTE_COW (1L << 8) // kernel/memlayout.h #define COWCOUNTSZ ((PHYSTOP - KERNBASE) / PGSIZE) #define COWCOUNT(pa) (((uint64)(pa)-KERNBASE) / PGSIZE) // kernel/kalloc.c struct { struct spinlock lock; unsigned char count[COWCOUNTSZ]; } cowcounts; char cowcount(uint64 pa, int flag) { char count = 0; acquire(&cowcounts.lock); if (flag > 0) { cowcounts.count[COWCOUNT(pa)]++; } else if (flag < 0) { cowcounts.count[COWCOUNT(pa)]--; } count = cowcounts.count[COWCOUNT(pa)]; release(&cowcounts.lock); return count; } 对于未使用的物理页块,其引用计数为 0,当通过 kalloc() 申请一个新的物理页块时,会将其对应的引用计数设置为 1。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // kernel/kalloc.c void *kalloc(void) { struct run *r; acquire(&kmem.lock); r = kmem.freelist; if (r) { kmem.freelist = r->next; } release(&kmem.lock); if (r) { memset((char *)r, 5, PGSIZE); cowcount((uint64)r, 1); } return (void *)r; } 追踪 xv6 源代码可以看到,执行 fork() 时会调用 uvmcopy() 来拷贝父进程的内存空间给子进程并映射到其页表上。当使用 COW 机制时,需要舍弃拷贝页面的操作,转而直接在子进程的页表上将虚拟地址映射到父进程的物理页块上,同时为物理页块的引用计数 +1。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // kernel/vm.c int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; for (i = 0; i < sz; i += PGSIZE) { if ((pte = walk(old, i, 0)) == 0) { panic("uvmcopy: pte should exist"); } if ((*pte & PTE_V) == 0) { panic("uvmcopy: page not present"); } pa = PTE2PA(*pte); if (*pte & PTE_W) { *pte &= ~PTE_W; *pte |= PTE_COW; } cowcount(pa, 1); flags = PTE_FLAGS(*pte); if (mappages(new, i, PGSIZE, (uint64)pa, flags) != 0) { // 由于之前增加过计数器,映射失败需要释放一个计数器 kfree((void *)pa); goto err; } } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; } 值得注意的是,Lab 的单元测试中会涉及到对 text segment 的修改。而参考 xv6 的进程内存模型,可以看到 text segment 属于只读,如果对它加上 PTE_COW 标志,则在单元测试中会报错,所以只修改可写页,为其加上 PTE_COW 标志。 需要修改内存 当一个进程需要修改内存时,修改不可写的物理页块会触发 Store page fault,在 kernel/trap.c 的 usertrap() 则可以捕获这个 page fault,并完成 COW 机制的 copy 操作。 首先需要在 usertrap() 中捕获 Store page fault,通过 r_scause() 读取 scause 寄存器,获取状态码,15 表示 Store page fault,其他状态码如图所示。并通过 r_stval() 获取发生 Store page fault 错误的虚拟地址。然后会调用 writecowpage() 来处理 COW 页,如果返回值为 -1 时,将当前进程杀死。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // kernel/trap.c void usertrap(void) { // ... if (r_scause() == 8) { // system call } else if (r_scause() == 15) { // Store page fault uint64 va = r_stval(); if (va > p->sz) { printf("Error: The virtual address greater then this process's size\n"); p->killed = 1; } else if (writecowpage(p->pagetable, va) != 0) { printf("Error: This page is not a cow-page or xv6 don't have enouth page, so not allow to write\n"); p->killed = 1; } } // ... } 在 writecowpage() 中,会根据虚拟地址映射的物理页块,确定其引用计数。如果大于 1,说明有多个进程共享这个物理页块,那么需要拷贝其内容到一个新申请的物理页块上,交由进程修改;如果等于 1,说明只有一个进程使用这个物理页块,那么直接修改其标志位,允许写即可。 通过 uvmunmap() 可以将原来的物理地址取消映射,在 uvmunmap() 中调用 kfree() 减少引用计数;并通过 mappages() 将虚拟地址映射到新申请的物理页块上。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // kernel/trap.c int writecowpage(pagetable_t pagetable, uint64 va) { va = PGROUNDDOWN(va); pte_t *pte = walk(pagetable, va, 0); if (pte == 0) { panic("COW: fail to get pte"); } uint flags = PTE_FLAGS(*pte); if (!(flags & PTE_COW)) { // this page is not a cow page printf("COW: This page from %p is not a cow page\n", va); return -1; } uint64 pa = PTE2PA(*pte); if (cowcount(pa, 0) > 1) { char *mem = 0; flags |= PTE_W; flags &= ~PTE_COW; if ((mem = kalloc()) == 0) { printf("COW: fail to kalloc, kill current process\n"); // 如果没有足够的页面则杀死进程 return -1; } memmove(mem, (char *)pa, PGSIZE); uvmunmap(pagetable, va, 1, 1); if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) != 0) { printf("COW: fail to mappages\n"); kfree(mem); return -1; } } else { *pte |= PTE_W; *pte &= ~PTE_COW; } return 0; } 在 copyout() 中,也需要考虑用户内存映射到的物理页块是不是 COW 页块,如果是的话也需要调用 writecowpage() 来实现 copy。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 // kernel/vm.c int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; while (len > 0) { va0 = PGROUNDDOWN(dstva); pa0 = walkaddr(pagetable, va0); if (pa0 == 0) { return -1; } if (cowcount(pa0, 0) > 1) { writecowpage(pagetable, va0); pa0 = walkaddr(pagetable, va0); } n = PGSIZE - (dstva - va0); if (n > len) { n = len; } memmove((void *)(pa0 + (dstva - va0)), src, n); len -= n; src += n; dstva = va0 + PGSIZE; } return 0; } 销毁 当一个进程结束,或者触发 Store page fault 并取消原有映射时,会调用 kfree() 来『释放』物理页块,是假释放还是真销毁,取决于物理页块的引用计数。当没有其他进程共享物理页块时,调用 kfree() 会彻底销毁这个物理页块并归入freelist;否则只是减少引用计数,做假释放。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // kernel/kalloc.c void kfree(void *pa) { struct run *r; if (((uint64)pa % PGSIZE) != 0 || (char *)pa < end || (uint64)pa >= PHYSTOP) { panic("kfree"); } // 首先减少引用计数 // 引用计数为 0 则表示最后一个进程释放了这个 page if (cowcount((uint64)pa, -1) != 0) { return; } // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); r = (struct run *)pa; acquire(&kmem.lock); r->next = kmem.freelist; kmem.freelist = r; release(&kmem.lock); } 实验后记 实际上 COW Lab 的相关概念很简单,清晰易懂,但是真上手了去实现又是另一种难度,需要注意非常多的细节,做好规划。 但实际上用时颇长,都是下班后的闲暇时间进行,但绝大多数时间都是在摸鱼划水。果然上班跟上学的感觉还是不一样,上了一天班回来只想瘫着,根本没有什么心思继续学习。这样不好,不好。 现在正瘫在出租屋的椅子上码这篇记录,码不动了,有种莫名其妙的心累。

2022/12/4
articleCard.readMore

【xv6】trap

何为 trap 当遇到一些事件时,会要求 CPU 从一般代码的执行转移到特殊的事件处理机制,这个机制就是 trap。也可以看作操作系统从用户态陷入内核态。 一般来说有三种情况会触发 trap: system call 通过 ecall 进入内核态; 程序访问越界或访问地址 0; I/O 设备引发中断,例如读写完成。 发生 trap 时,执行流从用户态转移到内核态,保存用户态的状态(寄存器、页表等),在执行完事件处理机制后,再回到用户态程序转移前执行的代码。trap 对于用户态程序来说是透明的,并不知道发生了什么。 system call 触发 trap system call 从用户态触发 traps,这里通过一张简单的图给出大致流程: 以用户程序调用 sleep() 为例,不会进入具体的 c 函数,而是通过 usys.S 中找到相关的入口,保存 sleep() 的调用号 SYS_sleep 到寄存器 a7,并通过汇编指令 ecall 陷入内核态。 当汇编代码中调用 ecall 时,首先会从 user mode 转换到 supervisor mode;其次会保存程序计数器 pc 到寄存器 sepc;最后会将 pc 设置成寄存器 stvec 保存的值,stvec 中保存了 trampoline page 的起始地址,这个页块中保存的是 uservec() 和 userret() 的汇编代码。 在跳转到 trampoline page 后, uservec() 会对诸多寄存器做处理。首先将寄存器 a0 的值保存到寄存器 sscratch 中,这样可以在 a0 中填入 TRAPFRAME,指向的是 trapframe page 的地址,xv6 会在这个页块中保存用户态下的寄存器。然后会将 trapframe page 保存的前几个值写入寄存器。16(a0) 所保存的是 struct trapframe 结构体中的 kernel_trap,这里指向了 trap.c/usertrap() 的地址。 跳转到 usertrap() 后,会获取当前进程的 struct proc,并将寄存器 sepc 保存到 proc->trapframe->epc 中,防止发生进程调度,覆盖这个寄存器。然后检查寄存器 scause,这个寄存器用于保存发生 trap 的原因的描述号。 system call 的描述号是 8,通过检查后会对 proc->trapframe->epc + 4,这是因为 proc->trapframe->epc 保存的是用户态中汇编指令 ecall 的地址,+4 后指向其下一个汇编指令。 然后通过 syscall.c/syscall() 找到 system call sleep() 真正的实现函数 sys_sleep() 并执行,保存其返回值到 a0 中。 当 system call 执行完成后,就需要从内核态回到用户态,在这之前要恢复环境。调用 trap.c/usertrapret(),关中断;变量 trampoline_uservec 计算得到 trampoline.S/uservec() 的地址,通过内联汇编保存到寄存器 stvec 中,方便下一次 system call 时使用;然后保存 kernel 相关寄存器的值到 proc->trapframe 中;变量 x 用于修改状态并保存到寄存器 sstatus 中;写入 ecall 下一个汇编指令的地址到寄存器 sepc 中,方便 pc 使用;变量 satp 记录用户态页表;变量 trampoline_userret 计算得到 trampoline.S/userret() 的地址,并以 satp 作为参数进行调用。 在 trampoline.S/userret() 中,将 trapframe page 中记录的值恢复到寄存器中,汇编指令 sret 将寄存器 sepc 中记录的值写入 pc,从 supervisor mode 恢复到 user mode,并开中断。这样就会回到用户程序并继续执行。

2022/10/5
articleCard.readMore

【xv6】system call

system call 调用过程 以 sleep 为例,追踪整个调用过程。启动 GDB 后,在 user/sleep.c 的 main() 处打上断点,然后在命令行中输入指令 sleep 25,会调用 user/user.h 声明的 sleep()。 步入后会调用 user/usys.S,可以看到 sleep 的调用汇编代码。首先将 SYS_sleep 的值写入寄存器 a7,然后调用 ecall 指令陷入内核态,进入真正的 system call,最后通过 ret 返回到用户级程序。 user/usys.S 的汇编代码由 user/usys.pl 脚本生成。 寄存器 a7 可以看到值最终修改为 13,对应了 kernel/syscall.h 中 SYS_sleep 的值。 调用 ecall 会切换到内核态,并调用 kernel/syscall.c/syscall(),获取 a7 中的调用号,然后根据调用号,在 *syscalls[] 中寻找对应的系统调用 sys_sleep()。 在找到对应的系统调用 kernel/sysproc.c/sys_sleep() 并执行后,会在其中获取保存在寄存器中的参数,并执行真正的实现 kernel/proc.c/sleep()。 如何实现一个 system call 以即将实现的 trace() 为例,描述实现一个 syscall 的具体流程。 首先在 kernel/syscall.h 中为新的 syscall 设定一个调用号,一般跟着上一个调用号顺延即可。 1 2 3 4 // ... #define SYS_mkdir  20 #define SYS_close  21 #define SYS_trace  22 然后在 user/usys.pl 脚本中添加一个入口,会在编译生成 user/usys.S 时为 trace() 生成用户态切换到内核态的汇编代码。 1 2 3 4 # ... entry("sleep"); entry("uptime"); entry("trace"); 如上一节所言,当调用 ecall 切换到内核态后,会根据从 a7 中取得的调用号来获取对应系统调用的入口,所以需要在 kernel/syscall.c 中声明 sys_trace() 并添加到 *syscalls[] 中。 值得一提的是,kernel/syscall.c 中也定义了多个用于获取参数的功能函数,在下文中会有使用。 1 2 3 4 5 6 extern uint64 sys_trace(void); static uint64 (*syscalls[])(void) = { // ... [SYS_trace] sys_trace, }; 在 kernel/sysproc.c 中需要定义 sys_trace() 的实现。sys_trace() 不会接受任何参数,那么用户调用时的参数如何获取呢?这就用到上面提到的功能函数,在 sys_trace() 中调用它们,从寄存器中获取参数,比如获取一个 int 类型参数: 1 2 3 4 5 6 7 8 9 10 11 uint64 sys_trace(void) { int mask; argint(0, &mask); // ... trace(mask); // ... return 0; } 拿到所需要的参数后,调用 trace(),这是系统调用的具体实现,我们定义的功能都在这个函数中实现。 最后记得在 user/user.h 中声明用户调用的 trace(),在 kernel/defs.h 中声明内核调用的 trace()。

2022/8/19
articleCard.readMore

如何用 C 实现协程

写在前面 本文是针对南大蒋炎岩老师 M2: 协程库 的实现总结。如果你也正在做这个 lab,请退出本文,仔细阅读实验文档,多写多调,虽然很难很痛苦,但是真的很有意思。如果你已经完成,欢迎邮件联系讨论相关内容,包括实现思路和相关代码交流。 关于这个实验 从开始阅读文档,到迄今为止完成 64bit 的测试用例(是的,32bit 测试用例还没过),耗费了大量的时间,一点一点摸索。揪着头发在脑子里运行协程,用 GDB 一行一行调试代码,逼急了甚至逐步过汇编代码,无时不让我感慨 coding 和 debug 真难,也真有意思。 2022-07-25 update: 使劲折腾终于用一种无语的方法把 32bit 测试用例通过了,此刻心情复杂。 协程 经过这段时间的 coding,让我大概对 协程 有了一些理解。 线程是 CPU 调度的最小单位,通过系统调用,陷入内核态来完成线程管理和调度;相比较进程而言,调度时上下文更少,因此开销更小一些。然而,抢占式调度终究还要看操作系统的脸色,陷入内核态还是会带来较大开销。 协程,我认为就是一种协作式调度的用户级线程。协作式,也就是由用户来决定调度时机,而不像线程用完时间片就被剥夺 CPU;用户级线程,无需陷入内核态,只要在用户态就可以完成逻辑流的切换。 除此之外,Linux 的线程栈大小默认为 8MB,一般来说用不到这么大;而协程可以自定义栈大小(可以通过修改系统文件来分配线程栈),相比较之下可以起更多的协程。 对于 I/O 密集型任务而言,CPU 用的少,需要更多的进程 or 线程来使用空闲的 CPU,提高利用率。协程相较于进程 or 线程而言,切换上下文开销更少,协程栈更小,就能开更多的协程来执行任务。 libco libco 定义了相关的 API: 1 2 3 struct co *co_start(const char *name, void (*func)(void *), void *arg); void co_yield(); void co_wait(struct co *co); co_start()负责创建一个协程,并指定任务函数及参数,创建好的协程不会直接运行,而是返回其指针等待用户指令; co_yield()负责在协程池中随机挑选一个待运行的协程,也就是『协作式调度』的切换函数; co_wait()暂停当前协程,直到指定协程运行结束后再继续当前协程,会在指定协程运行结束后释放其资源,所以用户应当保证初始协程外的每个协程被此函数执行一次。 main 函数所在的协程即为初始协程,通过调用co_yield()和co_wait()切换其他协程,并根据指定的任务函数和参数开始执行,直到函数返回 or 再次切换。 不论还有多少协程未执行结束,只要 mian 函数结束,整个进程即终止。 co 与协程池 定义协程,指定其协程栈大小为STACK_SIZE,64KB。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #define STACK_SIZE (64 * 1024) enum co_status { CO_NEW = 1,// 新创建但未执行 CO_RUNNING,// 正在执行 or 未执行结束 CO_WAITING,// 调用 co_wait 后暂停 CO_DEAD, // 执行完成,等待释放 }; struct co { const char *name; // 协程名 void (*func)(void *); // co_start 指定的任务函数 void *arg; // 任务函数参数 enum co_status status; // 协程状态 struct co *waiter; // 是否有其他协程在等待当前协程 jmp_buf context; // 上下文 uint8_t stack[STACK_SIZE];// 协程栈 }; 同时维护一个协程池,以双向链表的形式实现: 1 2 3 4 5 6 7 8 9 10 // 协程链表结点 typedef struct co_list_s co_list; struct co_list_s { struct co *node; co_list *pre; co_list *next; }; struct co *current_co = NULL;// 当前执行协程 co_list *list_head = NULL; // 协程链表 针对这个协程池,提供一些 API: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 /* * description: 当前协程尾插入到协程链表中 * param: {node-> 协程} * return: {0-> 成功; -1-> 失败} */ static int add_list_tail(struct co *add_node); /* * description: 从协程链表中删除协程指定的链表结点并释放资源(不会释放协程结点) * param: {del_node-> 待删除的协程} * return: {0-> 成功; -1-> 失败} */ static int del_list_node(struct co *del_node); /* * description: 生成一个在 [left, right] 中的随机数 * param: {left-> 范围下限; right-> 范围上限} * return: {int-> 随机数} */ static int random_num(int left, int right); /* * description: 从协程链表中随机抽取一个链表结点并返回协程 * param: {} * return: {struct *co-> 协程节点; NULL-> 链表无结点} */ static struct co *random_get_list_node(); 值得一提的是,通过random_num()生成伪随机数,以便随机获取协程池中的协程来调度;而不是简单的在链表上取头结点,仿佛队列一样。 co_start 当调用co_start()时,会在进程的共享内存中创建一个新的状态机,用以表示协程;并在协程创建后,将其压入协程池,方便协程调度时随机执行。主要通过malloc()在堆中为协程申请空间。这个函数实现非常简单,只是创建并初始化协程,然后压入协程池,有以下实现: 依据实验要求删除代码。 co_wait 这里先介绍co_wait()的实现。首先暂停当前协程,然后通过co_yield()调度到目标协程,等目标协程结束后释放其资源并调度回来。 依据实验要求删除代码。 执行co_wait()时,目标协程可能是两种情况: co->status = CO_DEAD,说明某处调用co_yield()时随机调度到了目标协程,且任务函数执行结束,这时只需要释放协程资源即可; co->status = CO_NEW || co->status = CO_RUNNING,说明目标协程未开始执行 or 执行了但没有执行完成,这时候暂停当前协程,然后通过调用co_yield(),以期调度到目标协程,任务函数执行结束后回到当前逻辑流,让暂停协程继续执行,释放目标协程资源。 可以看到,协程资源释放是通过co_wait()实现的,这就意味着要求用户对所有创建的协程,执行且只执行一次co_wait(),及时释放资源,防止内存泄漏。 co_yield 可以说,libco 的精髓就在于co_yield(),其实现了协程的协作式调度。 在研究其实现前,先研究研究『调度』。进程调度要求保存进程资源、保存上下文环境、通过调度算法选择下一个进程、加载新进程;线程调度要求保存上下文环境并切换线程。 而协程调度与线程调度类似,保存上下文环境然后切换即可。 setjmp.h 提供了相应的接口,用以上下文保存和恢复,这里的上下文对应了co->context。 依据实验要求删除代码。 状态机如下图所示。在调用co_yield()时,首先调用setjmp.h/setjmp()保存当前运行协程current_co的上下文,然后选择下一个协程。我们希望被选择的协程状态为CO_NEWorCO_RUNNING: CO_NEW表示被选择协程尚未执行,此时需要将其相关参数写入寄存器,然后进入其任务函数; CO_RUNNING表示被选择协程已开始执行,且其任务函数中也调用了co_yield()导致其被切换,此时只需通过setjmp.h/longjmp()重新加载上下文,就会回到任务函数中。 在协程的任务函数中,可能也会中途调用co_yield(),这时重复这个状态切换;也可能彻底执行完成,调整协程状态为CO_DEAD。观察co->waiter是否为空,不为空意味着针对此协程的co_wait()被调用过,通过setjmp.h/longjmp()恢复被暂停协程的上下文,释放协程资源;为空就通过co_yield()继续调度到其他可执行协程。 stack_switch_call co_yield()中保存和恢复上下文的setjmp.h很有意思,stack_switch_call()也是有趣的函数。这个函数通过扩展内联汇编,手动从co_yield()切换到协程的任务函数中;主要也是这个函数在 x86 和 x86_64 中有不同的实现。 实验文档中提供了示例代码,可以通过 以往的 lab 、 C 内联汇编 和 GCC-Inline-Assembly-HOWTO 来了解扩展内联汇编的相关知识。 通过改写实例代码,stack_switch_call()暂时(2022-07-21)通过了 x86_64 的测试用例。 依据实验要求删除代码。 2022-07-25 update: 32bit 的测试用例无法通过,是因为在call返回后会用到%esp,而此时%esp还指向协程栈中,没有恢复;64bit 不会用到%rsp,所以没有报错。 前面一直知道问题关键点在哪里,想要通过扩展内联来恢复寄存器,但是不论怎么写restore_sp(),汇编中都不会调用相关的扩展内联。今天实在无语,一不做二不休直接把恢复操作写在了stack_switch_call()的call指令后面,于是就成功了。哈哈哈哈什么玩意儿,呸。 依据实验要求删除代码。 co_ptr->stack通过malloc()申请在堆上,生长方向与内存地址增大方向一致,但是栈是反过来的,所以用来模拟栈时需要反着使用。传入co_ptr->stack + STACK_SIZE表示栈底,并将其写入栈指针;将任务函数参数co_ptr->arg写入相应寄存器 or 栈地址作为第一个也是唯一一个参数;通过call写入返回地址并进入任务函数co_ptr->func。 通过 GDB 可以看到call后的返回地址与co_ptr->stack + STACK_SIZE -0x8的值相等,总共占 6 个内存空间共 6 字节。

2022/7/22
articleCard.readMore

利用 nginx-upload-module 实现文件上传和重命名

近来因为毕设需求,需要有一个简单的文件上传服务,结合 Nginx,最终通过 nginx-upload-module 实现。但是该模块开发者考虑到同时间同名文件上传碰撞,将文件名统一设置为一串经过计算得到的数值,交由后端服务对文件做改动。 本来不是啥大问题,只是网上教程都使用其他语言实现文件重命名,我实在不想因为这么一个简单的需求在毕设中引入其他后端语言,于是决定自写一个模块实现文件重命名功能,源码保存在 nginx-upload-rename-module 中。 毕设内容是基于 Nginx 实现流媒体点播服务器,打算自写一个支持 HLS 协议的模块,实现从 .mp4 到 .m3u8 的转码切分。正好写这个重命名模块,学习 Nginx 模块开发。 此次实验在本地机上进行,在服务器上部署基本一致。 ❗ 请注意文末的提示! 思路 nginx-upload-module 的工作流程是将上传文件从 HTTP 报文中剥除,组合成文件,计算特定数值后保存到指定目录;其他文件信息则经过整合写入 HTTP 报文的请求体,交由后端处理。 我本打算直接调用它定义的变量,获取诸如文件名、文件类型等信息,直接完成文件重命名,但是可能是它变量定义时对标志位的设置,无法获取索引也没有记录入散列表,无法获得其值,就此作罢。 输出其 HTTP 报文的过程中,偶然明白了 nginx-upload-module 的 HTTP 报文传递到后端时的具体内容,于是决定对 HTTP 报文做处理,获取其特定字段,保存后用以处理文件。 编译 首先下载 Nginx 源码和两个模块: 1 2 3 wget https://nginx.org/download/nginx-1.20.2.tar.gz git clone https://github.com/fdintino/nginx-upload-module.git git clone https://github.com/xQmQ/nginx-upload-rename-module.git 解压缩 Nginx 源码后,有以下三个目录: 1 2 3 4 . ├── nginx-1.20.2 ├── nginx-upload-module └── nginx-upload-rename-module 进入nginx-1.20.2,准备编译。这里我直接将安装路径放在了当前目录: 自行安装 Nginx 源码编译时需要的依赖 1 ./configure --prefix=$(pwd)/nginx --add-module=../nginx-upload-rename-module --add-module=../nginx-upload-module 经过检查后得到以下信息: 然后执行编译: 1 make 编译成功后安装: 1 make install 这样就得到了配置文件和二进制文件: 配置 首先将nginx-upload-rename-module/UploadSuccess目录复制入nginx/html中。这个目录里有上传成功后返回给前端的页面。 然后修改 Nginx 的配置文件conf/nginx.conf,在http{}中设置以下信息,监听的端口自定,我这里就直接监听 1080 端口。 注意配置upload_store时,指定的目录要存在,且有相应的权限,目录可以自定;client_max_body_size决定上传文件的大小;其他配置项可以看看 ngin-upload-module 的 README。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 server { listen 80; client_max_body_size 100m; # Upload form should be submitted to this location location /upload { # Pass altered request body to this location upload_pass /uploadSuccess; # Store files to this directory # The directory is hashed, subdirectories 0 1 2 3 4 5 6 7 8 9 should exist upload_store /tmp; # Allow uploaded files to be read only by user upload_store_access user:rw group:rw all:rw; # Set specified fields in request body upload_set_form_field $upload_field_name.name "$upload_file_name"; upload_set_form_field $upload_field_name.content_type "$upload_content_type"; upload_set_form_field $upload_field_name.path "$upload_tmp_path"; # Inform backend about hash and size of a file upload_aggregate_form_field $upload_field_name.md5 "$upload_file_md5"; upload_aggregate_form_field $upload_field_name.size "$upload_file_size"; upload_pass_form_field "^submit$|^description$"; upload_cleanup 400 404 499 500-505; } # Pass altered request body to a backend location /uploadSuccess { upload_files_rename on; } } 启动 设置好后,进入sbin/,准备启动: 我没有写前端上传的页面,打算利用 Apifox 直接调用接口。 POST 请求,并插入待上传的文件,访问localhost:1080/upload。 我这里在本地机做实验。 可以看到返回了成功界面。 实际上进行上传并调用upload_pass后即会返回此页面,不代表文件重命名成功,但是一般情况下都是成功的。 也可以看到指定的目录下有了对应的文件: 而如果没有启动 nginx-upload-rename-module,一般来说文件名如下,一串数字所示: 提示 因为个人能力有限,模块可能会存在 bug,已经测试过多个不同名文件同时上传,没有什么问题。 但是正如 nginx-upload-module 开发者所说,存在文件碰撞的可能,所以他们决定让用户在后端程序(如 PHP)中移动和重命名文件。我没有条件测试同一上传时间的同名文件碰撞。 但是对于同文件名但不同内容的文件 A 和文件 B,先上传文件 A,再次上传文件 B 后,会删除 文件 A 保留文件 B,即没有设置同名文件碰撞处理,请知悉。 其次,由于模块开发过程中挂载方式的选择,upload_pass指定的location /uploadSuccess可能无法启动其他模块的功能,只能启动本模块(暂未测试)。 所以开发的模块只适用于简单的文件上传服务,请小心使用。

2022/3/23
articleCard.readMore

在 WSL2 的 Arch Linux 下编译并替换内核

闲来无事,想把 WSL2 的 kernel 升级一下。GitHub 中微软官方发布的 WSL2 kernel 最新版本为5.10.74.3,但是 Microsoft Update Catalog 中一直没有相应版本的安装包(截止本文发布) 于是打算自己动手编译一下 编译环境 WSL2 下的 Arch Linux 编译内核 整体编译思路就是利用微软的编译配置文件来编译 Linux kernel,并在相应的 Windows 目录下替换现有的内核 这里放一下 GitHub 中的 WSL2-Linux-Kernel 下载源码 在 GitHub 下载相应版本的 releases 1 wget https://github.com/microsoft/WSL2-Linux-Kernel/archive/refs/tags/linux-msft-wsl-5.10.74.3.tar.gz 下载完成后解压缩并进入目录 1 2 tar zxvf linux-msft-wsl-5.10.74.3.tar.gz cd WSL2-Linux-Kernel-linux-msft-wsl-5.10.74.3/ 为确保内核树绝对干净,进入内核目录并执行make mrproper命令 1 make clean && make mrproper 安装编译工具 微软也在项目里告知了步骤,其中也包括编译工具 这个不能照葫芦画瓢直接复制下来,需要在 ArchWiki 中找一下 Arch Linux 中的包 build-essential、flex和bison都集成在 Arch Linux 的base-devel里了,其他两个也有相对应的包 1 sudo pacman -S base-devel openssl libelf pahole xmlto kmod inetutils bc 关于pahole: 在多次编译时,最终都会报错 1 2 3 BTF: .tmp_vmlinux.btf: pahole (pahole) is not available Failed to generate BTF for vmlinux Try to disable CONFIG_DEBUG_INFO_BTF 一种方法是在Microsoft/config-wsl中对CONFIG_DEBUG_INFO_BTF设置关闭 ✨ 另一种方法是下载pahole,此方法取自 Stack Overflow,其他发行版遇到此问题也可以看一下 关于其余四个包: 由 ArchWiki 推荐 2022-02-08更新: 在最新推出的5.10.93.2版本中,官方修复了关于以上提到的BTF报错,针对 Ubuntu 给出依赖项dwarves 修改编译配置文件 打开Microsoft/config-wsl文件,把内核号修改了一下,改成自己的名字 开始编译 1 make KCONFIG_CONFIG=Microsoft/config-wsl -j 编译开始时会出现编译选项,全部按照默认就可以了 然后就可以等着编译完成了 可以看到编译完成的内核所在的路径为arch/x86/boot/bzImage 这就是编译好的 WSL2 的内核了 替换内核 WSL2 使用的内核是放在 Windows 目录下的C:\WINDOWS\System32\lxss\tools 只需要把编译好的bzImage复制到此路径下,并更名为kernel即可 可以使用文件管理器复制内核 1 explorer.exe . 关闭所有 WSL,并将bzImage复制到C:\WINDOWS\System32\lxss\tools下并更名为kernel 替换内核前需要将所有 WSL 关闭 启动 Arch Linux,并截图 🌈 可以看到内核已经替换为5.10.74.3版本了

2022/1/29
articleCard.readMore

如何找到一个适合自己的笔记软件

这两天我基本上都在花时间找合适自己的笔记软件,感觉颇为痛苦,就此写一篇文章,梳理自己的需求 理由 为什么要找笔记软件呢?我现在的笔记基本上就是一些零散的纯 markdown 文本,涉及到编程的一些笔记则是通过 blog 发布出来,在浏览器上查阅。前不久 通过 GitHub Actions 实现博客的自动部署,做到对 blog 的云编辑及实时部署 但是一番思考,我有可能会关闭 blog,理由如下: 我有可能不会续费服务器(因为云服务器对我来说只是简单的部署 blog 图片部署在阿里云 OSS 上,在 markdown 中写下的图片链接名称极其丑陋,日后关闭图床,备份图片到本地必然极其麻烦 我没有太多的表达欲(毕竟连朋友圈都不喜欢发),blog 对于我来说更多是当作知识库 我想记录一些东西,和我的笔记放在一起,但是不想在 blog 上让人看到(虽然没几个人看我的 blog 笔记间没有联动,撑死了靠链接实现 如果关闭了 blog,那我如何整理我的笔记呢?可能需要一些其他的笔记软件来解决了 尝试 notion: 优点: 功能强悍(网上宣传的 好看(我体会出来的 教程丰富 模板丰富 缺点: 不完全支持 markdown,导入导出 markdown 格式有问题 没有 vim 模式 在墙外,不稳定 数据在云端,不在本地 wolai: 优点: 与 notion 类似的功能 对中文有更好的支持 好看 文档丰富 模板丰富 在国内,速度快 缺点: 不完全支持 markdown(优于 notion,劣于思源笔记 没有 vim 模式 数据在云端,不在本地 在我看来,有些功能的付费要求纯属恶心人 思源笔记: 优点: 类似与 notion 的块式笔记 相比较以上两种,对 markdown 支持更好 数据存储在本地 除云端同步外其他功能全部免费 有自定义主题集市(但主题不多 官方发布 docker 镜像 缺点: 没有 vim 模式 相比较 notion 和 wolai,没有那么好看(自定义主题也不行 部署 docker 后,在浏览器进行编辑(浏览器的菜单栏和收藏夹占了好大一片地,全屏模式又不能和其他网页分屏 多端访问编辑需要借助同步软件来同步数据,不能利用 docker 进行同步(就目前为止我没有发现其他方法 开源了,但没有完全开源(只是在 GitHub 上挂了文档,没有源码 需求 那我究竟需要什么功能呢?哪些是必须的?哪些又是相对来说可以舍弃的? 必须: 优化对 markdown 的支持,编辑后与 Typora 展示的效果类似(比如引用里可以设置有序列表,wolai 就不支持 类似于 notion,笔记间相互联动 尽量美观,不卡顿 数据在本地(包括笔记、图片和配置文件 导入导出 markdown 便利,且处理的较好 尽可能: 提供 vim 模式 提供 docker 镜像实现云同步(或其他方法实现自动云同步 提供主题修改 对于 TODO List 功能,我没有要求,滴答清单可以很好的满足我 现状 不知所措 ing 2022-04-17 更新 现在主要在用 Obsidian,在我看来主要优点有: 笔记存储为 markdown 格式,且数据存储在本地 支持 vim 模式 有中文论坛 有自定义主体 第三方插件可以满足一些需求 暂时还没有痛点存在,在使用 Obsidian 时也在逐渐摸索构建属于自己的知识体系的方法论,正在看《卡片笔记写作法》这本书

2021/11/19
articleCard.readMore

为终端设置 ASCII ART

突发奇想,想在进入 WSL 时,显示用于欢迎的 ASCII ART,最好还是彩色的。找了诸如figlet、toilet等工具,都不好用,于是写了一个 python 脚本来解决这个问题 虽然这玩意儿没什么用,但是搞的好看心情舒畅哇 😄 成果图如下,可以实现随机色彩: 这里用到了 python 库:pyfiglet,用于实现字符串转变为 ASCII ART;然后通过增加颜色代码,来实现随机色彩输出 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #!/bin/env python import random import pyfiglet text = " Hello, xQmQ" font = "slant" color_code = {'red': '\033[31m', 'green': '\033[32m', 'yellow': '\033[33m', 'blue': '\033[34m', 'violet': '\033[35m', 'azure': '\033[36m' } color = random.choice(list(color_code.keys())) string = color_code[color] + pyfiglet.figlet_format(text, font) + '\033[0m' print("\n" + string) 然后通过写入~/.zshrc来默认启动 1 python $HOME/.script/rainbow-ascii-art.py 代码挺简单,但是在测试的时候发现折磨人的问题了 一开始,可以在打开 shell 时正常输出 ASCII ART,但是通过 ranger 的S命令进入当前停留目录时,也会输出 通过排查 ranger 的S命令,得到如下 1 map S shell $SHELL ranger 通过$SHELL打开一个新的子 shell,打开子 shell 时会调用配置文件~/.zshrc,就会重复输出 想到通过更改 ranger 的S命令,启动终端的同时设置一个环境变量,子 shell 通过判断环境变量是否存在,来判断当前 shell 是不是 rnager 打开的子 shell 最终更改如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 diff --git a/.config/ranger/rc.conf b/.config/ranger/rc.conf index de26803..44c3f53 100644 --- a/.config/ranger/rc.conf +++ b/.config/ranger/rc.conf @@ -376,7 +376,7 @@ map <A-k> scroll_preview -1 map ? help map W display_log map w taskview_open -map S shell $SHELL +map S shell export ranger=1 && $SHELL map : console map ; console diff --git a/.script/rainbow-ascii-art.py b/.script/rainbow-ascii-art.py new file mode 100644 index 0000000..d9a9630 +++ b/.script/rainbow-ascii-art.py @@ -0,0 +1,16 @@ +#!/bin/env python + +import random +import pyfiglet + +text = " Hello, xQmQ" + +font = "slant" + +color_code = {'red': '\033[31m', + 'green': '\033[32m', + 'yellow': '\033[33m', + 'blue': '\033[34m', + 'violet': '\033[35m', + 'azure': '\033[36m' + } + +color = random.choice(list(color_code.keys())) + +string = color_code[color] + pyfiglet.figlet_format(text, font) + '\033[0m' + print("\n" + string) diff --git a/.zshrc b/.zshrc index e4873f0..8f411bb 100644 --- a/.zshrc +++ b/.zshrc @@ -19,12 +19,11 @@ alias ps='procs' alias ls-'lsd' alias lg='lazygit' +# 登录欢迎 +if [[ -z $ranger ]]; +then + python $HOME/.script/rainbow-ascii-art.py +fi # 设置代理 export hostip=$(cat /etc/resolv.conf |grep -oP '(?<=nameserver\ ).*')

2021/9/22
articleCard.readMore

Qver - 用于练手的服务器程序

利用 C++ 11 编写一个简单的 Linux HTTP 服务器,主要用于静态页面的部署 使用非阻塞 socket + epoll + 半同步/半反应堆模式 + 线程池来处理客户连接 通过有限状态机处理 HTTP 请求报文(GET) 项目地址:xQmQ/Qver: C++ 11 实现的简单 HTTP server 函数 httpEvent(int fd):推入到线程池任务队列的处理函数,通过fd创建HttpEvent类型对象,调用相应的处理函数,完成对 HTTP 请求的处理 类 HttpEvent:HTTP 请求处理类 ThreadPool:线程池实现,通过ThreadPool::submitWork()推送任务并唤醒工作线程处理 Socket:注册并绑定 socket 连接到服务器特定端口,接受所有客户连接 Epoller:epoll的封装实现,用于接收socket接受的连接并监听其是否有读写请求;通过定时器剔除非活动连接 Timer:定时器,用于处理非活动连接 处理流程 主线程 主线程建立Socket对象,建立ThreadPool对象 非阻塞的Socket对象源源不断接收连接并注册到Epoller监听,并插入Timer定时器 Epoller监听到就绪读请求时,获取对应fd并通过httpEvent(int fd)注册到线程池任务队列,由线程池唤醒工作线程并处理;同时从Timer中剔除链接 通过Timer定期处理非活动连接,从Epoller中剔除并关闭 监听外部信号,关闭线程池,结束进程 工作线程 线程池唤醒工作线程并从任务队列中获取httpEvent(int fd) 工作线程建立HttpEvent对象,绑定fd HttpEvent获取fd中的数据到读缓冲区,通过有限状态机解析 HTTP 请求,并准备相应资源写入写缓冲区,并发送给客户 两种事件处理模式 半同步/半反应堆模式 流程: 建立线程池 主线程监听 socket,当接收到一个客户端 connect 时,accept 并将其注册到 epoll 内核事件表 通过epoll_wait()获得客户端的读写请求,将读写请求插入任务队列 工作线程通过申请互斥锁,获得读写请求,并处理,返回结果到客户端 缺点: 主线程向请求队列添加事件或工作线程从请求队列取出事件,即生产者-消费者问题,需要通过互斥锁完成同步。加锁和解锁浪费 CPU 当请求队列中的读写事件过多时,工作线程较少,无法及时处理,会减慢客户端响应时间 高效半同步/半异步模式 流程: 建立线程池,初始化线程,各线程新建 epoll 内核事件表,新建私有请求队列 主线程监听 socket,当接收到一个客户端 connect 时,accept 连接 主线程轮询线程池中所有工作线程的状态,挑选负载最小的工作线程并传递 fd 到工作线程的私有请求队列中 详情:《Linux 多线程服务端编程》9.1.2 小节 工作线程轮询请求队列,如果有 fd 则注册到自己的 epoll 内核事件表中,通过epoll_wait()获得客户端的读写请求 工作线程处理读写请求,返回结果到客户端;查看请求队列是否还有事件需要处理 遇到的问题 任务队列的empty()和size()都设置为const,与函数中上锁的操作发生了冲突 类ThreadPool中定义了一个返回类型推导的任务提交函数submitWork(),类中的函数声明与定义分离在Threadpool.h和ThreadPool.cpp中,但是模板函数的声明和定义不可以分离。我的解决办法是将submitWork()定义在Threadpool.h中 第一版的ThreadPool可能存在线程不安全的情况。在构造函数中,创建工作线程并向工作线程暴露了ThreadPool的this指针,如果ThreadPool初始化到一半,其他线程会访问这个半成品;析构函数中也存在问题,在代码中存在唤醒工作线程执行余下的任务这样的操作,这些工作线程会调用ThreadPool::conditional_mutex_,如果析构函数销毁了互斥锁,对于未结束的工作线程来说会破坏互斥环境 《Linux 多线程服务端编程》1.2,1.3 线程池设计思路出现问题。线程池的关闭应当由shutdown()来定义,不应该放在析构函数中,何时执行shutdown()?考虑到这是一个服务器,不用关闭,需要关闭的时候应当由外部信号来决定是否调用shutdown() epoll 检测事件时需要关注,socket 是否被客户端主动关闭,通过addEvent()时对检测的事件调整为EPOLLET | EPOLLIN | EPOLLRDHUP;且处理EPOLLIN之前需要先close()对端关闭的 socket epoll 对于EPOLLRDHUP和EPOLLIN | EPOLLOUT的处理需要注意:应当优先处理EPOLLRDHUP,然后再处理其他操作 epoll 对于EPOLLOUT的触发问题。高位表示缓冲区可写,低位表示不可写。在水平触发LT中,由低位转高位和处于高位这两种情况都会触发EPOLLOUT;在边缘触发ET中,只有低位转高位才会触发EPOLLOUT。一般来说,在连接建立时,缓冲区是可写的,这时就会触发,所以处于ET时,后续操作中会存在无法触发EPOLLOUT导致无法执行写操作,可以通过epoll_ctl()中EPOLL_CTL_MOD重新设置一次EPOLLOUT 当客户端主动关闭一个 socket(操作一),并且紧接着申请一个新的 socket 连接时(操作二)(两个操作中间没有其他客户端的操作),会导致操作二继承操作一的文件描述符。当操作一发生后,文件描述符并没有从定时器链表中去除;操作二继承的文件描述符会继续以操作一的定时来确定是否是非活动连接,这里需要注意处理。 对于检测信号中的结构struct sigaction.sa_handler,其记录的是信号触发时的处理函数,以回调函数的形式表示。且此处理函数只能有一个参数,为信号值。在原本的设计中,计划将这些涉及到信号处理的函数封装成类,但是如果封装成类,处理函数作为成员函数时,无法作为回调函数绑定在struct sigaction.sa_handler(此时有两个参数,一个是隐藏参数this) 参考 《Linux 高性能服务器编程》 《Linux 多线程服务端编程》 《Linux / Unix 系统编程手册》 《HTTP 权威指南》 《C++ 服务器开发精髓》

2021/9/14
articleCard.readMore

关于我在宿舍种草的那些事

2021.3.1 前几天闲来无事,脑袋一拍,打算整点啥种一下。比较种花家嘛,不种点东西玩,总觉得没意思。思来想去,我这手艺种啥都挺危险的,干脆种草把。这玩意儿总比我好养活吧 😥 说干就干,网上买了个小的粉色花盆,买了小铲子还顺便送了包草种,完美! 外面下了好几天雨了,二话不说跑宿舍楼底下挖了些土。说实话应该等几天,天气好一点,干了以后土好歹不会黏成块,但是太心急了,就没管那么多。花盆底下填了好些土,然后把草种撒上,再撒些土盖上。因为是下过雨的土,含水量高,就没再浇水了。 放窗台上,等着吧,看我能种出来不 🥳 2021.3.3 我寻思不太对劲了,总感觉土会板结在一起。再考虑要不要重新填松散的土,然后用水浇透。 2021.3.8 今天站在窗台前放松眼睛,头一低发现长出来好多芽儿,好耶 😁 2021.3.16 每天都泡在图书馆,没怎么管过草,都是偶尔看一下浇点水。现在已经长的很高了

2021/3/1
articleCard.readMore