(UDP) 打洞技术是通过中间公网服务器的协助在通信双方的 NAT 网关上建立相关的映射表项,使得双方发送的报文能直接穿透对方的 NAT 网关(防火墙),实现 P2P 直连。
洞:所谓的洞就是映射规则,外部能够主动与之通信的规则
通常我们说的打洞技术基本上都是使用 UDP 来实现的,当然 TCP 也行,只不过会复杂一点(后面我们讨论一下 TCP 打洞)。
主要利⽤第三⽅的服务器作为中转服务器,⽐如Application Level Gateway (ALG),application server, application server with agent, TURN。
有点是稳定可靠,缺点是延迟较⼤,不适合在p2p的⽹络中使⽤。
本质上其实不算 P2P 打洞的范畴了。
基本思路是:A 和 B 互相知道对方的公网 IP:Port,使用对方公网 IP:Port 通信。
通过修改通信协议,或者 NAT 设备来帮助节点之前的直接通信,⽐如: tunnel, NAT-PMP, UPnP, MidCom, STUN hole punching, ICE, Teredo等。 其中STUN hole punching⽅法在实践中应⽤最为 广泛。
Full Cone NAT: 允许任何外部 IP 任何端⼝连⼊ NAT,只要 NAT 内部 host 产⽣过 IP 端⼝映射。 (不限制任何 IP)
Restricted Cone NAT: 只允许外部指定 IP 连⼊当前 NAT,即 NAT 内 host 主动连 接过的 IP。(限制 IP)
Port-Restricted Cone NAT:只允许内⽹设备主动 连接过的外⽹ IP 和 Port 连⼊。(限制 IP + Port)
Symmetric NAT:这种类型的 NAT ⾏为跟端⼝限制型的 NAT 类型相似,不同的是,对于向外连接的不同的 IP 和 Port,NAT 随机分配⼀个 Port 来完成地址转换,完成对外连接。(只要向外的 IP:Port 不一致则映射到不同端口)
注意:对于前三种 NAT 设备,内网节点连接不同的 server {IP, Port} 对映射外网端口一致;
主要有以上几种,市面上还有少量的其他类型
举例如下:
在 Full Cone NAT 的基础上多加了一条限制规则:
举例如下:还是上面的例子,后续只允许 https://www.google.com/ (对应 IP 203.107.53.50) 与之通信,而不 care 203.107.53.50 的端口号(比如从 6666 端口过来的数据)。
在 Restricted Cone NAT 的基础上多加了一条限制规则:
举例如下:还是上面的例子,后续只允许 https://www.google.com/ (对应 IP 203.107.53.50) 的 80 端口与之通信,其他端口不行。
访问规则同 Port-Restricted Cone NAT:
区别是连接不同的 server {IP, Port},映射到 NAT 上的公网 Port 不一致,且映射规则不确定。
有些 NAT 设备会进行简单的 +1 操作实现端口映射,比如:{local_port: 6000, public_port: 6000, Server1},{local_port: 6000, public_port: 6001, Server2},{local_port: 6000, public_port: 6003, Server3}
有些 NAT 设备为了安全性,可能会随机进行端口映射,提高端口猜测的难度。
(假设已经获取到⽬标邻居的IP Port 等信息)
注意:A 和 B 均要与 server 一致保持连接心跳,确保 NAT 映射端口有效
打洞策略:
打洞策略:
节点 A 和 B 的 NAT 可能是任意一种 NAT 类型
锥形 NAT 之间可以容易的打洞成功,具体流程如下:
以下流程假定已经完成了 NAT 类型探测,A/B 知道自己的 NAT 类型,以及通过 NAT 映射出去的端口 PA1/PB1。
打洞策略:
注意,上面斜体部分,NAT 对不明地址的行为可能是拒绝,待会会讨论。
假设 A 是对称 NAT,B 是普通锥形:
打洞策略:
如果 A 和 B 正好角色相反,那么可以调整打洞的方向即可
原本大致过程是同上面一种场景,但是由于 B 是端口限制型 NAT,会导致 PB1 只允许 PA1 通过(上面红色字体部分B 已经为 A 在 PB1 打好洞),从而 PA2 过来的包会被 B 的 NAT 拒绝,导致打洞失败。
由于 A 和 B 均是对称型 NAT,那么比上面一种场景更严格,A 和 B 探测得到的公网 Port 均会被修改,无法完成打洞。
我们再来考虑对称型和端口限制型的打洞,由于 B 收到 server 转发过来的打洞请求后,是向 PA1 发送探测包的,因为 B 只知道 PA1(PA1 是 A 与 server 连接是映射的端口号,server 也只知道 PA1),但是 A 由于是对称型 NAT,会从一个新端口 PA2 向 B 发包,但是 B 由于是端口限制型,只允许 PA1 端口的包通过,所以 B 会拒绝 PA2。
还是上面那张图:
A 从 PA2 发向 B 的包一直会被 B 拒绝,也就是说 B 无法在 NAT-B 上为 A 打洞。
那假如 B 探测包不是发往 PA1 而是 PA2 呢?那 A 和 B 就能打洞成功。
那么问题来了,B 如何知道 PA2 呢?
通常来讲,有两种办法:
对于对称型的 NAT在映射内网端口的时候,有一些 NAT 设备会采取比较傻瓜的端口分配方法,比如进行简单的线性变化。
对于这种 NAT,要探测这种特性需要用到两台及以上的公网 server,通过与不同的 server 连接映射的公网 Port,归纳总结自己的 NAT 映射规律,那么对于 B 来说,打洞的时候第一次向 A 发包,就直接往 PA2 发包就好了。
有一些对称型 NAT 为了安全考虑,分配端口的方法难以预测,比如随机分配端口,那么对于这种情况,如何预测端口号呢?
生日攻击理论讲的是在一个班级里,每个人的生日可能是 365 天里的任何一天,每年有 365 天,如果要让 至少有两人的生日相同的概率超过 50%,问这个班级最少需要多少人?
答案是:(xx)
是不是出乎预料?
生日攻击理论说的直白点就是,利用了远小于样本集的尝试次数,就能够很大概率获得两个相同的碰撞采样结果。
那么针对端口号的样本集 65535,实际是 (1025, 65535],双方随机打洞需要尝试多少次(打多少洞)才能刚好碰撞成功呢?
1 | cat nat_birth_attack.py |
运行结果:
1 | >>> python nat_birth_attack.py ‹git:master ✘› 11:00.53 四 3 25 2021 >>> |
把 total 修改成 65535,概率 rate 修改成 80%,计算得到尝试次数为 460 次。
1 | total:65536 trytimes:460 result:0.801039 > target_rate:0.8 success |
也就是说对于 B 来说,可以尝试随机往 A 的 460 个不同的端口发探测包,就有 80% 的概率能够正好预测到 NAT-A 随机分配的 PA2。
460 个探测包的代价基本可以忽略不计。
至此,可以完美实现对称型和端口限制型的打洞。然而遗憾的是,对于对称型和对称型打洞,依然无法实现。
上面能够打洞成功的场景下,都是基于一个前提是 NAT 对陌生地址发来的包采用的是丢弃策略。这里的陌生地址指的是自己没有主动往外发包的 {dest_ip, dest_port} 对。
如果不是丢弃,而是采用黑名单机制呢?为了安全考虑,有一些 NAT 在收到陌生地址的包后,会触发防火墙模块,并且在自己的 deny 列表中增加一项{PA2, PB1},随后自己再往 A 发包的时候,本来打算使用 PB1 进行发包,但是发现 deny 列表里已经存在了 PB1,于是会重新选择一个端口号 PB2 发包。于是对于这种锥形 NAT 会退化成对称型的 NAT。
知道了这个原理,要解决也很容易。
一开始 A 往 B 发包,可以设置 TTL 为 3,这个数大到足够通过自己的外网 NAT(可能有多层),又会被中间的某个运营商 router 丢弃,从而不会惊动 B 的防火墙模块,同时为 B 打好了洞。
同理,B 也做类似的操作,为 A 打好洞
A 和 B 两边都等待一段时间,比如 2 s
再互相发探测包,不用设置 TTL
打洞成功
关于 TTL 的值设置为多少,需要做一定的探测,不然可能设置过小,也许都没有走出自己的 NAT,设置过大,可能导致惊动了对方的防火墙
NAT | 全锥形 | 限制型锥形 | 端口限制型锥形 | 对称型 |
---|---|---|---|---|
全锥形 | Direct | Direct | Direct | Direct |
限制型锥形 | Direct | Hole Punch | Hole Punch | Hole Punch |
端口限制型锥形 | Direct | Hole Punch | Hole Punch | Hole Punch |
对称型 | Direct | Hole Punch | Hole Punch | Relay |
TCP 也能实现 NAT 打洞,只不过相比 UDP 会更复杂一点。原因是:
基本打洞策略如下:
在 NAT 上的映射规则有失效时间,如果要保持洞口的有效性,需要保持打洞双方的心跳。比如在手机上,这个洞口可能会在 1 min 后失效
我感觉其实单层 NAT 应该是类似的。
Blog:
2021-03-28 于杭州
By 史矛革
这是前段时间在公司内部关于 paxos 做的一次技术分享,主要围绕 basic-paxos/multi-paxos 协议进行,并会对 raft 协议进行一些对比,简单提及了一下 pbft。
取名“深入浅出 paxos”,意思是从分布式模型的简化和抽象系统,讲到分布式数据一致性的核心问题,再引出 paxos 协议的核心,再从纯理论的 basic-paxos 到落地工程实践的 multi-paxos,最后对比 raft、pbft 协议,从简入深,再从深到核心,再到工程实践。
由于是一次技术分享,所以和我之前的技术博文不太一样,有些东西并没有完全写到博客里,包括一些现场讨论等,所以可能读完之后对 paxos 理解效果会差一点。
先认识名词,从整体上有一些概念,战略上藐视。
本文重点分析 paxos (basic paxos) 算法。顺带会提及 multi-paxos 以及 raft 算法。
paxos 很难理解?争取听完本次分享,大家能彻底理解 paxos!
先忘记区块链,忘记 pbft,忘记 hotstuff.
为什么要有分布式系统? 单机容易故障,无法保证服务高可用。
于是出现多副本模型,但多副本模型就存在两个问题:
我们首先把整个模型抽象一下,到最简单的模型。为此,我们定义两个操作:
在这里先不考虑并发,不考虑正确性,不考虑其他操作,也不考虑多个值。
只有这两个操作,而且只操作数据 X,X 初始值为 null.
根据上面的抽象,我们定义复制成功的要求就是:
如果执行了 SET X,那么 GET X 一定能取到值
不满足复制成功的要求
失联节点不确定,写入 slave 个数也不确定,不满足复制成功的条件
写入 slave 个数不确定,最少 1 个,最多 n 个,有概率性存在无法获取 X, 不满足复制成功的要求
满足复制成功的要求,先多数派写,再多数派读。
优点:
到此,我们解决了成功复制的问题。
首先,策略我们已经确定,要保证复制成功,需要先多数派写,再多数派读。
扩展一下刚才的抽象,考虑有多个客户端并发的来写(SET X),那么读到的值将不唯一。
这里我们依然不考虑正确性的问题,我们只考虑唯一性的问题。
抽象是很重要的,能够帮我们简化模型,思考本质的问题。
如何解决并发的问题,或者说多个 client SET X 的问题?
如果 SET X 前运行一次多数派读,知道 X 的值可能已经有别人写入了,那么就不写,如果还没有人写入,那么就写入。
道理很简单,但问题是这涉及到两次 rtt,所以其他 client 就有可能插入进来。
怎么解决呢?
这里依然有多数派读的要求,Client A 必须收到 a quorum (>n/2) 个响应才认为本次写前读取 true,后续可以放心大胆的写入;否则判定为 false,认为已经有其他节点优先读取了,放弃后续的写。
有了这个保证,那么 Client A 在第二个 rtt 就可以放心的写了。(参考上面的图)
到这里其实就已经讲到 paxos 算法的核心了。(终于讲到 paxos 了)。
也许,我们还可以问两个问题:
图灵奖大牛 Leslie Lamport (莱斯利·兰伯特)
来自知乎:
Lamport在分布式系统理论方面有非常多的成就,比如Lamport时钟、拜占庭将军问题、Paxos算法等等。除了计算机领域之外,其他领域的无数科研工作者也要成天和Lamport开发的一套软件打交道:著名的LaTeX。这是目前科研行业应用最广泛的论文排版系统,名字中的”La”就是指Lamport。
实际上Paxos在1990年就被提出了。当时Lamport写了一篇名为《The Part-time Parliament》的论文,在这篇文章中作者讲了一个虚构的故事。这个故事发生在希腊的神话中的一个名叫Paxos的岛屿(也就是算法名称的来由),作者将分布式一致性的问题比喻为岛上的立法机构如何对一项决议达成一致的问题。Lamport本来是觉得用故事加以描述更易理解;但其结果完全相反。这篇文章当时的评审几乎没有人看懂,只有一位名叫Butler Lampson的计算机科学家读懂了故事,并意识到这是一篇解决分布式一致性问题的论文。当然这篇论文就被埋没了多年,原文1998年才得以发表,后来Lamport也又重新“正儿八经”地写了一篇《Paxos Made Simple》。
https://lamport.azurewebsites.net/pubs/paxos-simple.pdf
from wiki:
In order to guarantee safety (also called “consistency”), Paxos defines three properties and ensures the first two are always held, regardless of the pattern of failures:
Validity (or non-triviality)
Only proposed values can be chosen and learned.[15]
Agreement (or consistency, or safety)
No two distinct learners can learn different values (or there can’t be more than one decided value)[15][16]
Termination (or liveness)
If value C has been proposed, then eventually learner L will learn some value (if sufficient processors remain non-faulty).[16]
Note that Paxos is not guaranteed to terminate, and thus does not have the liveness property. This is supported by the Fischer Lynch Paterson impossibility result (FLP)[6] which states that a consistency protocol can only have two of safety, liveness, and fault tolerance. As Paxos’s point is to ensure fault tolerance and it guarantees safety, it cannot also guarantee liveness.
开始之前,记住刚才讲到的系统抽象(实际上 paxos 也仅仅只是解决了这个问题,想清楚这个系统抽象,理解起来会容易得多,避免走弯路)
paxos 定义了 3 种角色:
Leaners 暂时不重要,可以不用关心
Tips: 这里有一些概念可能比较难以理解,由于中英文的差异,阅读论文的时候可能会比较苦恼
举个例子:
如果你阅读 paxos 的一些文档,可能会经常看到 ”value be choosen” 等一些概念,这里的 choosen 就可以理解为值已经确定,唯一。
主要过程分为两个阶段(如同上述讨论的两个 rtt)
先来看几个概念:
proposer 发起 “prepare” 请求,请求里携带一个 proposal number n,这个 n 要比它之前的 prepare 消息使用的值大。然后它发送(广播)这个 “prepare” 消息给 a Quorum of Acceptors(大多数的 acceptors)。
值得注意的是,这个 prepare 消息通常不包含具体的提案 v。(正如上面的写前读取,不需要携带将要写的值 x)
acceptor 收到从某个 proposer 发过来的 prepare 消息,拿到消息里的 proposal number n.
先看几个概念:
这里要注意:
第一阶段对比之前的讨论,就相当于之前的写前读取,用来判断谁准备写入。但是区别是: 上面的例子中只接受第一个 client 的写前读取,后续其他的 client 的写前读取全部拒绝;而这里的 acceptor 其实是允许接收多个 prepare(写前读取) 的,想想看为什么? 相同的地方是都需要对写前读取做记录 (minProposal = n)
如果 proposer 收到了 a Quorum of Acceptors返回的 promise,那么说明他可以开始提案了。
如果 promise 里含有 (acceptedProposal, acceptedValue),那么就放弃自己原本的提案,从返回的这些 promise 里挑出 acceptedProposal 最大的 acceptedValue 作为本次的提案 value。
(很重要,我自己初看的时候对这里放弃自己的提案很是想不通,还是那句话,把系统抽象到最简模型,提案的具体值不重要,重要的是达成一致)
这里的理解: accepted 并不表示某个提案 v 被确定了(be choosen),之所以放弃自己的提案,其实是相当于继续了未完成的 paxos 过程。后面会讲到。
如果返回的 promise 里都没有 acceptedValue 值,那么才使用自己的提案 value (说明这是第一次提案)
然后 proposer 发起 “Accept” 请求 (n, v),广播给大多数 acceptor。
acceptor 收到 “Accept” 消息后(n, v),取出里面的 proposal number n.
注意,acceptor 是可以接受多次 acceptedValue 的,只要满足上面的条件。
当 proposer 收到了大多数的 acceptor 返回来的 “Accepted” 消息,则认为这个提案已经确定(be choosen)。
如果返回的消息有任何 minProposal >n,则重新以更大的 proposal number n执行 paxos.
接下来,就是 Leaner 发挥作用的时候了,Leaner 就会复制这个提案。实际的做法可能有多种,比如 Leaner 和 proposer 集成到一个角色,再通知其他的 Leaner 这个被 choosen 的提案v; 或者 Leaner 自己执行一遍 paxo是就能知道被 choosen 的提案。
讨论一下上面关于 n 与 minProposal 的大小关系:
或者类似 pbft 的流程图:
到这里,其实已经把 paxos 的核心算法讲完了。
来讨论一下涉及到的几个过程中可能出现的场景。
对比 deadlock,叫法很形象
到了这里,你是否还有很多疑问?
我自己看到这的时候,我的疑问就是假设提案v 已经被确定了,即 be choosen, acceptor 保存的 acceptedValue 和 acceptedProposal 难道要一直保存吗?如果我打算发起第二个提案 v’, 按照上面的算法,promise 还是会把 (acceptedValue, acceptedProposal) 返回来,我始终无法发起第二个提案 v’.
更直白的话是,实际的系统肯定是一些连续的不同的提案,比如add, sub, jump, mov, cmp 等等,或者举一个更容易理解的数据库的例子,我们是持续的往数据库里写入数据的:
1 | x = 1 ; y = 2; z= 100; x + 100; z - 10 ... |
当我们成功发起 x=1 这个提案之后,按照 paxos 的流程发起第二个提案 y=2,还是会得到 x=1 这个提案的值。
basic/classical paxos 仅仅只是解决了一个提案(一次操作)的数据一致性问题,这也是纯理论的 basic/classical paxos 解决的核心问题。
你是否跟我一样?如果你能想到这里,说明你已经理解了上面的算法!(👏👏)
那么应用到实际中,怎么解决呢?
根据上面的讨论我们可以知道,执行一个 paxos 流程可以解决一个提案的一致性问题,如果想要发起第二个提案,就势必要处理 (acceptedValue, acceptedProposal) 的问题。
在 Acceptor 上记录了 3 个值 (minProposal, acceptedValue, acceptedProposal),当第一个提案(比如 x=1) 成功被 choosen 之后,Acceptor 上必须要有一个机制去清除掉这 3 个值,就像最初什么也没发生过时的状态,此时发起第二个提案(y=2) 就和发起第一个提案(x=1) 一样了。
那么问题就来了,Acceptor 什么时候清除 (minProposal, acceptedValue, acceptedProposal) 呢?它必须知道提案已经被 choosen 了,并且要记录下这个已经被 choosen 的提案 (x=1);否则不能清除。
所以当一个提案(x=1) 被 choosen 之后,发起提案的 proposer 需要广播这个 choosen 结果给 Acceptor,直到大多数 Acceptor 返回成功。(先提一下:这里这个过程是不是和 raft 里 leader 日志复制的第二个过程类似,即 leader 本地 commit 之后,广播给所有 follower,告诉他们这个 command 已经处于 commited 状态了,然后 follower commited command)
Ok, 到这里 Acceptor 已经知道提案被 choosen(commited) 了,实际做法是记录下这个 choosen 的提案(x=1), 然后它可以放心大胆的清除(minProposal, acceptedValue, acceptedProposal) 这 3 个值了。那么当 proposer 发起第二个提案 y=1后,Acceptor 就能正常处理了。
但, Acceptor 怎么区分这两个不同的提案(x=1 和 y=1) 呢?(靠 proposal number n ?)
我们还得给每一个不同的提案一个编号,或者说 index,即每一个不同的提案具有唯一的序号 index。所以一个 proposer 发起一个提案(prepare 消息)时需要包含 (n,v, index),那么 Acceptor 这里呢,记录的 3 个值 (minProposal, acceptedValue, acceptedProposal) 和每一个 index 唯一绑定,或者说每一个不同的 index 有自己单独的 3 个值, 即(minProposalindex, acceptedValueindex, acceptedProposalindex)
是不是可以并发了呢?
经过上面的讨论,我们看到如果给每一个不同的提案 x = 1 ; y = 2; z= 100; x + 100; z - 10 ...
做一个编号 index,我们就能够独立的执行 paxos 协议,实现我们实际工程中的连续写操作。
由纯理论到工程实践了!!!!
我们把上面每个运行独立 paxos 协议的实体称为一个 paxos instance,每个 paxos instance 独立互不影响。实际的工程中我们通常也会把 proposer/acceptor/leaner 3 个角色放到一个节点上,即一个 paxos 节点具有多重身份,每个身份可以作为一个线程运行,方便数据共享(比如 choosen value)。
性能问题的解决:
据此 multi-paxos 提出一个 Leader 机制,避免 proposer 的冲突,这样一样,所有发起的提案全部由这个 Leader 来发起(至于如何选取 leader,问题不大,有很多做法,就如同 raft 的 leader 选举一样),同时也解决了 livelock 问题。
那么既然有了 leader,第一阶段的 prepare/promise 还有必要吗? 第一阶段的 prepare/promise 到底是干嘛的,上面其实已经讲的很清楚了,就是确认哪一个 proposer 准备写,那么既然有了 leader,自然这个过程就可以省略了。
所以 multi-paxos 就只有 accept/accepted 过程了。
那么我们再重新思考一下:basic paxos 的两个阶段到底解决了什么问题?
通过 multi-paxos leader 机制,我们简化了第一阶段:
这两个过程是不是和 raft 如出一辙,在 raft 里叫 log replication?😁😁 multi-paxos ≈ raft.
先看一个状态机:
client 发起一个新的 command jmp,进入共识模块执行 multi-paxos 算法,会去找到最小的已经被 choosen(commited) 的 index, 找到 index = 3(放弃自己的提案 jmp,执行 index=3 的 paxos), and so on…
注意,实际可能同时并发发起 3 ~ 20 index 的提案。
multi-paxos 是允许日志空洞的,也就是不连续的,每次 leader 并发发起 index 任意多个的不同提案,每个提案独立进行,所以会有成功和失败。
”幽灵复现日志“造成的原因就是”日志空洞“和 Leader 切换。
举个例子:
leader 选举的过程可以简单理解为某个节点 A 发起一轮 basic paxos(提案就是选 A 作为 leader),最终提案被 choosen,广播给所有 acceptor,于是 leader 产生,并利用 lease 机制保持自己的 leader 身份,避免其他的 proposer 发起竞选 leader.
lease 机制:即租约机制,声明 leader 有效期,在有效期内,不允许发起竞选 leader;超过 lease,随意进入选举。
leader 切换必然伴随日志不一致的问题,即当前 leader 的日志和前任 leader 的日志不一致。就有可能造成 client 查询的时候返回 false。详细就不展开讲了。
上面每个 index 构成的本地提案记录,类似于一个列表,raft 里就 log entry,index 称为 logid.
先看一个动画:
http://thesecretlivesofdata.com/raft/
raft 协议和 multi-paxos 很像。
先不讨论 leader 选举的问题,应该很简单。就讨论 log replication(日志复制)的问题。raft 过程如下:
定义了 3 种角色:
过程如下:
很简单,每个 follower 持有一个计数器,比如 [100, 300]ms,在这段时间内只要收不到 leader 的 hearbeat,就认为 leader 挂掉(实际有可能是他自己出了问题),然后由 follower 状态转为 candidate 状态,开始竞选 leader.
每个节点只能投一票,如果这个 candidate 收到大多数节点的 vote,则成为 leader,更新任期号 term number.
没关系,只要达不成 quorum vote,就继续下一轮投票;
为了避免出现无休止的重复这个过程(类似 livelock),每次重新开始竞选时,随机延迟一段时间,避免出现两个 candidate 竞选。
脑裂就是网络分裂,形成两个或者多个独立的孤岛网络,假设分裂成 A 和 B 网络。
对于 B 来说,由于不满足多数派,故日志始终处于 uncommited 状态,所以是安全的;
对于 A 来说,正常进行 raft 共识;
一旦网络恢复:
所以也是安全的。
基本和 multi-paxos 一样,但区别是 raft 要求日志是连续的,不允许出现日志空洞。即如果 logid = index 处于 commited 状态,那么 logid <index 的一定都处于 commited 状态。
先看一下概念上的一些区别:
再看一下其他方面:
祭一张图吧 (分享时间有限,先提一下 pbft,以后再分享。)
关于 paxos, 欢迎和我一起交流。如果上面有讲的不对的地方,欢迎指正。
Blog:
2020-12-05 于杭州
By 史矛革
事情是这样的,观察到某台机器上出现了卡死的现象,即没有刷新日志,cpu 使用也较低,怀疑是不是出现了死锁。
由于程序采用的是 master + worker
的模式,首先 gdb attach 观察 master 情况,发现 master 执行正常,没有 lock wait 相关的堆栈;然后 gdb attach 观察 worker 情况,结果发现 worker 堆栈上有 lock wait 的情况,果然是出现了死锁,但 worker 上的其他线程并没有发现在等待锁的情况。
根据堆栈,找到 worker 的代码,重新梳理了一下代码,检查了 std::mutex 相关的函数调用,并没有出现嵌套调用的情况,也没有出现递归调用的情况,和上面发现 worker 其他线程没有等待锁的情况相吻合。
说明 worker 的死锁,并非由于 worker 内部的多线程造成的。那么就很诡异了,不是 worker 内部死锁,难道是多进程死锁?
重新又检查了 worker 各个线程的堆栈情况,发现确实只有一个线程出现 lock wait 相关的堆栈; 并且又检查了一下 master 进程内部的各个线程,堆栈也都正常。
那 worker 锁住的这个线程,到底是因为什么原因?梳理 worker 代码,找到 std::mutex 相关的函数调用,发现 master 调用的一个函数使用到了 std::mutex,但是该函数内部逻辑也较为简单,不会一直占用这把锁。
没有头绪,谷歌搜索了一些类似的问题,找到了一点端倪。主进程 fork 之后,仅会复制发起调用的线程,不会复制其他线程,如果某个线程占用了某个锁,但是到了子进程,该线程是蒸发掉的,子进程会拷贝这把锁,但是不知道谁能释放,最终死锁。
确实符合这个程序的行为,并且确实是多进程下子进程的死锁,而且找不到其他线程也在等待锁。
接下来,写一个 demo 验证一下,是否 fork 不会复制子线程,并且有可能造成死锁。
简单写一个 demo:
1 | // file: fork_copy_thread.cc |
上面的代码简单解释一下:
使用编译命令:
1 | $ g++ fork_copy_thread.cc -o fork_copy_thread -std=c++11 -lpthread -ggdb |
运行后与预期不符,子进程并没有死循环打印字符串,死锁了。
然后使用 gdb attach 子进程:
1 | (gdb) bt |
果然可以看到子进程卡在了 print_str() 函数上。
上面的代码,父进程创建线程后,占用了锁,此时 fork 了子进程,子进程拷贝了父进程空间的内存,包括锁,但是没有复制子线程,造成子进程无法获取锁,最终死锁。
上面已经验证了死锁的产生原因是由于 fork 时并没有把父进程里的线程复制到子进程,导致子进程无法获取锁。那么简单修改一下上面的代码,来验证一下子进程确实是没有复制父进程的子线程。
1 | // file: fork_copy_thread.cc |
简单解释一下修改了啥:
执行结果:
1 | $ ./fork_copy_thread |
可以看到只有一个线程在打印,也就是父进程创建的那个线程;fork 之后父进程的线程在子进程蒸发了。
多线程程序使用 fork 一定要谨慎,再谨慎,并且也不推荐这样的做法。
1 |
|
Copy On Write(写时复制)技术大大提高了 fork 的性能。fork 之后,内核会把父进程中的所有内存页都设置为 read-only,然后子进程的地址空间指向父进程。如果父进程和子进程都没有涉及到内存的写操作,那么父子进程保持这样的状态,也就是子进程并不会复制父进程的内存空间;如果父进程或者子进程产生了写操作,那么由于内存页被设置为 read-only,所以会触发页异常中断,然后中断程序会把该内存页复制一份,至此父子进程就拥有不同的内存页;而其他没有操作的内存页依然共享。
上面这段话不太好理解,涉及到的东西其实比较深也比较多。我们把它拆开来说。
虚拟内存空间,进程是看不见物理内存地址的,进程的内存空间称为虚拟内存,默认从 0 到 max,虚拟内存空间也就是逻辑内存地址,进程操作的都是逻辑内存地址。
虚拟内存地址到真实的物理内存地址的转换或者映射称为地址重定向,有专门的中断程序来负责处理,作为进程本身不需要关心。
物理内存的单位是页,也就是内核使用页为单位来管理物理内存,数据结构上页其实是一个 struct,大小好像是 4KB。虚拟内存地址映射到物理内存以页的方式进行,并且内核管理一个页映射表。
malloc 分配内存,其实操作的是虚拟内存,也即使用 malloc 分配了一段内存后,在未赋值之前,其实是没有物理内存占用的,当真正向 malloc 分配的内存写数据的时候,内核才会分配真实的物理内存页,并让这段虚拟内存指向实际的物理内存页。并且进程管理一个页表。
进程的虚拟内存空间,由地地址到高地址空间大致分为代码段、数据段、BSS 段、堆、栈,详情如下:
fork 之后,子进程复制了父进程的虚拟内存空间,即复制了代码段、堆栈等,所以变量的地址也是一样的。并且父子进程各自有一份页映射表,它们都指向父进程的物理内存地址。
当父子进程只读时,不会发生真实的物理内存拷贝;但是当父子进程写入时,由于物理页 read-only,会触发页异常中断,中断程序会把该页面复制一份,其他的页保持不动。至此父进程和子进程的页映射表就出现了一点不一致了,但其他部分还是一致的。
要理解 fork 的原理,Copy On Write 的原理,重点是理解虚拟内存和物理内存的关系。
fork 之后,子进程会复制父进程的虚拟内存空间,也就是代码段、数据段、堆栈等,虚拟内存空间里表达的就是程序里各个变量的地址,所以子进程里各个变量的地址和父进程里各个变量的地址是一样的。
父子进程只读时,不会发生真实的物理内存拷贝,他们的页映射表内容一致,即同样的虚拟内存地址指向同样的物理内存地址;但当有一方写入数据时,内核会复制要写入的页,此时修改数据的一方的页映射表就发生了变化,即同样的虚拟内存地址指向了不同的物理内存地址,但其他部分还是一样的;
另外,fork 仅会将发起调用的线程复制到子进程中,所以子进程中的线程 ID 与主进程线程 ID 有一致的情况。其他线程不会被复制。
关于 fork 的细节,还有很多值得深入研究的东西。
Blog:
2020-11-21 于杭州
By 史矛革
说起 tcp 的连接过程,想必 “3次握手4次挥手”是大家广为熟知的知识,那么关于更细节更底层的连接过程也许就很少人能讲清楚了。
所以本文会先简单回顾一下 tcp 的 3次握手过程,然后重点聊一下 tcp accept 的过程,涉及到 tcp 半连接队列、全连接队列等的内容。
要了解 3 次握手的过程,可能需要先熟悉一下 tcp 协议的格式:
source port
和 dest port
,分别表示本机端口以及目标端口,在 tcp 传输层是没有 IP 的概念的,那是 IP 层 的概念,IP 层协议会在 IP 协议的头部加上 src ip
和 dest ip
;注意: ack 和 ACK 是不一样的意思,一个是确认号,一个是标志位
了解了 tcp 协议的头部格式,那么再来讲一下 3 次握手的过程:
开一个终端执行以下命令作为服务端:
1 | # 服务端 |
然后打开新的终端用 tcpdump 抓包:
1 | # -i 表示监听所有网卡; |
然后再打开一个终端模拟客户端:
1 | $ nc 127.0.0.1 10000 |
观察 tcpdump 的输出如下:
1 | IP Jia.22921 > 192.168.1.7.ndmp: Flags [S], seq 614247470, win 29200, options [mss 1460,sackOK,TS val 159627770 ecr 0], length 0 |
分析以下上面的结果可以看到:
第一个包 Flags [S] 表示 SYN 包,seq 为随机值 614247470;
然后服务端回复了一个 Falgs [S.],也就是 SYN+ACK 包,同时设置 seq 为随机值 1720434034,设置 ack 为 614247470 + 1 = 614247471;
客户端收到之后,回复一个 Flags [.],也就是 ACK 包,同时设置 ack 为 1720434034 + 1 = 1720434035;
上面是正常情况的握手情况,假如握手过程中的任何一个包出现丢包呢会怎么样?比如受到了攻击,比如服务端宕机,服务端超时,客户端掉线,网络波动等。
所以接下来我们分析下 3 次握手过程中涉及到的连接队列。
https://linux.die.net/man/3/listen
The backlog argument provides a hint to the implementation which the implementation shall use to limit the number of outstanding connections in the socket’s listen queue. Implementations may impose a limit on backlog and silently reduce the specified value. Normally, a larger backlog argument value shall result in a larger or equal length of the listen queue. Implementations shall support values of backlog up to SOMAXCONN, defined in <sys/socket.h>.
1 | int listen(int socket, int backlog); |
backlog 参数是用来限制 tcp listen queue 的大小的,真实的 listen queue 大小其实也是跟内核参数 somaxconn 有关系,somaxconn 是内核用来限制同一个端口上的连接队列长度。
完成 3 次握手的连接,也就是服务端收到了客户端发送的最后一个 ACK 报文后,这个连接会被放到这个端口的全连接队列里,然后等待应用程序来处理,对于 epoll 来说就是内核触发 EPOLLIN 事件,然后应用层使用 epoll_wait
来处理 accept 事件,为连接分配创建 socket 结构,分配 file descriptor 等;
那么假如应用层没有来处理这些就绪的连接呢?那么这个全连接队列有可能就满了,导致后续的连接被丢弃,发生全连接队列溢出,丢弃这个连接,对客户端来说就无法成功建立连接。
所以为了性能的考虑,我们有必要尽可能的把这个队列的大小调大一点。
可以通过一下命令来查看当前端口的全连接队列大小:
1 | $ ss -antl |
在 ss 输出中:
LISTEN 状态:Recv-Q 表示当前 listen backlog 队列中的连接数目(等待用户调用 accept() 获取的、已完成 3 次握手的 socket 连接数量),而 Send-Q 表示了 listen socket 最大能容纳的 backlog。
非 LISTEN 状态:Recv-Q 表示了 receive queue 中存在的字节数目;Send-Q 表示 send queue 中存在的字节数;
接下来我们实际测试一下,使用项目:mux。
我们先修改一下 backlog 参数为 5:
1 | # 把backlog 调小一点 |
根据编译文档,编译后得到两个二进制:
1 | $ ls |
bench_server
用来作为服务端,底层使用 epoll 实现bench_client_accept
作为压测客户端,并发创建大量连接,这里只会与服务端建立连接,不会发送其他任何消息(当然可以用其他的压测工具)选择两台机器进行测试,192.168.1.7 作为服务端, 192.168.1.4 作为压测客户端,开始压测前,可能需要设置一下:
1 | ulimit -n 65535 |
1) 启动服务端
1 | # 192.168.1.7 作为服务端,监听 10000 端口 |
注意到上图执行 ss -antl
看到 10000 端口的 listen queue size 为 5,这里是故意调小一点,为了验证全连接队列溢出的场景。
2) 先观察一下服务端全连接队列的情况以及溢出的情况
1 | $ ss -natl |grep 10000 |
上述表明 10000 端口的 listen queue size 为 5,并且全连接队列中没有等待应用层处理的连接;
netstat -s |grep -i overflowed 表示全连接队列溢出的情况,2683 是一个累加值。
2) 启动 tcpdump 对客户端行为抓包,分析 3次握手连接情况
1 | # 运行在 client: 192.168.1.4 上 |
3)启动压测客户端
1 | # 192.168.1.4 作为压测客户端 |
压测过程中,可以不断执行命令观察服务端全连接队列溢出的情况,压测完毕之后再观察一下全连接队列溢出的情况:
1 | $ ss -natl |grep 10000 |
可以看到,压测过程中的 Recv-Q 出现了5,1 的值,表示全连接队列中等待被处理的连接,而且有 2930 - 2283 = 647 次连接由于全连接队列溢出而被丢弃。
我们再来观察一下 bench_client_accept
的日志情况:
1 | $ grep -a 'Start OK' log/bench_client_accept.log |wc -l |
可以看到最终有 264 个 client 由于服务端丢弃建立连接时 3 次握手的包而造成连接失败。
如果你细心的话会发现,全连接队列溢出发生了 647 次,但是最终只有 264 个 client 建立失败,why?其实原因很简单,因为客户端有重试机制,具体参数是 net.ipv4.tcp_syn_retries
,这个暂且不详说。
那再来看一下 tcpdump 抓包的结果,这里要用到一个 python 脚本 tcpdump_analyze.py
来处理一下 tcpdump.log 这个日志:
1 | import os |
运行后得到结果:
1 | $ python tcpdump_analyze.py |
上面的意思是总共有 29736 个 client 成功建立连接,而有 264 个 client 建立失败;连接成功的 client 里有 29195 个是通过了正常的 3 次握手成功建立,没有发生重试;而有 541 个 client 是发生了重试的情况下才建立连接成功。
可以看到上面的输出,发生重试 “succ with retry” 的部分,client 发送一个 SYN 之后,由于 server 全连接队列溢出导致连接被丢弃,client 超时后重新发送 SYN 包,然后建立连接;
而上面连接失败的客户端,错误原因都是: errno = 110,也就是 “Connection timed out”。
Ok,到现在应该明白全连接队列大小对于 tcp 3 次握手的影响,如果全连接队列过小,一旦发生溢出,就会影响后续的连接。
那我们修改一下 backlog 的大小,改大一些:
1 | listen(listenfd, 100000); |
然后我们修改内核参数:
1 | net.core.netdev_max_backlog = 400000 |
可以通过打开 /etc/sysctl.conf
直接修改,或是通过命令修改:
1 | $ sysctl -w net.core.netdev_max_backlog=400000 |
重新编译运行,执行上述的压测,观察结果。
压测前:
1 | $ ss -natl |grep 10000 |
压测后:
1 | $ netstat -s |grep -i overflowed |
可以看到,当我们把内核参数以及 backlog 调大之后,30000 个 client 全部建立连接成功且没有发生重试,服务端的 listen queue 没有发生溢出。
全连接队列存放的是已经完成 3次握手,等待应用层调用 accept()
处理这些连接;其实还有一个半连接队列,当服务端收到客户端的 SYN 包后,并且回复 SYN+ACK包后,服务端进入 SYN_RECV 状态,此时这种连接称为半连接,会被存放到半连接队列,当完成 3 次握手之后,tcp 会把这个连接从半连接队列中移到全连接队列,然后等待应用层处理。
那么怎么查看半连接队列的大小呢?没有直接的 linux command 来查询半连接队列的长度,但是根据上面的定义,服务端处于 SYN_RECV 状态的数量就表示半连接的数量。所以采用一定的方式增大半连接的数量,看服务端 SYN_RECV 的数量最大值有多少,那就是半连接队列的大小。
那问题就来了,如何增大半连接的数量呢?这里采用到的就是 SYN-FLOOD 攻击,通过发送大量的 SYN 包而不进行回应,造成服务端创建了大量的半连接,但是这些半连接不会被确认,最终把 tcp 半连接队列占满造成溢出,并影响正常的连接。
采用的工具是: hping3,一款很强大的工具。
启动服务端:
1 | # 192.168.1.7 作为服务端,监听 10000 端口 |
开始攻击:
1 | $ hping3 -S --flood --rand-source -p 10000 192.168.1.7 |
观察半连接数量:
1 | $ netstat -ant |grep SYN |
持续观察,可以看到处于 SYN_RECV
状态的连接基本保持在 256,说明半连接队列的大小是 256。而此时,10000 端口已经比较难连接上了。
查看一下半连接队列的丢弃情况:
1 | $ netstat -s |grep dropped |
注意: 26055883 是一个累加值,可以持续观察
那怎么增大半连接队列大小呢?
直接修改内核参数:
1 | # 直接修改文件 /etc/sysctl.conf |
或者使用命令:
1 | $ sysctl -w net.ipv4.tcp_max_syn_backlog=100000 |
据说半连接队列并非只由这个参数决定,不同的系统的计算方式不一致,还会和全连接队列大小有关
当然这个应对 SYN-Flood 攻击只是轻微降低影响而已。
还可以设置 net.ipv4.tcp_syncookies = 0
来一定程度防范 SYN 攻击。
syncookies 的原理就是当服务端收到客户端 SYN 包后,不会放到半连接队列里,而是通过 {src_ip, src_port, timestamp} 等计算一个 cookie(也就是一个哈希值),通过 SYN+ACK包返回给客户端,客户端返回一个 ACK 包,携带上这个 cookie,服务端通过校验可以直接把这个连接放入全连接队列。整个过程不需要半连接队列的参与。
上面压测验证全连接队列溢出的场景下,通过 tcpdump 抓包分析到有些连接是经过了重试才建立成功的,具体表现在:
客户端发送 SYN 包请求建立连接,但此时由于服务端全连接队列溢出或者半连接队列溢出,该 SYN 包就会被丢弃,当客户端迟迟无法收到服务端的 SYN+ACK 包后,客户端超时重发 SYN 包,如果再次超时,那么根据内核设置的 SYN 超时重试次数决定是否继续重发 SYN 包。
假设重试次数为 6 次:
所以当我们发现服务端出现了问题的时候,可以适当提高 SYN 重试的次数;当然过大的值也会影响问题的快速发现;
可以通过设置:
1 | $ sysctl -w net.ipv4.tcp_syn_retries=2 |
Ok, 到这里基本上把 tcp 3 次握手比较细节的地方讲到了。 tcp 真是一个巨复杂的协议,还有不少值得深挖的东西!
Blog:
2020-11-14 于杭州
By 史矛革
之前其实已经写过一篇博文: 迁移博客到香港虚拟空间,那为什么又要写这篇博客呢?
上次其实是把我的博客迁移到一个香港的虚拟空间里,但是不到半年的时间已经出现过 4 次宕机事件,每次持续时间 4~5 小时,阿里云 和 UpTimeRobot 的监控报警报了一大堆,邮箱都快塞满了。想着宕机就宕机吧,至少还能恢复,还能凑合用,结果呢,就在前几天当时购买虚拟空间的官网都 GG 了,管理员跑路了。。。
可能他没挣到钱吧,买一台服务器打算开很多共享的虚拟空间来卖,可能也只有我买了一个,因为我后来看了下我的博客同 IP 的网站就两个,好嘛,结果就跑路了。。。这里就不点名是哪一家了,八字开头的一个云。
好吧,言归正传,正好双 11,那就干脆直接买服务器吧,所以就购买了腾讯的一台轻量级云服务器,峰值 30Mbps,月流量 1024G,能满足我的需求,况且有了服务器,能做的事情就很多了。比如我还有其他的博客也可以解析到这里,比如可以定制化一些动态博客,比如可以使用自动化发布等。
那本文大致就记录下迁移的一些过程以及踩坑优化等:
双 11 活动,购买了一台轻量级的腾讯云服务器。然后就是初始化服务器,登录服务器,设置 ssh key 登录等。
注意这里一定要设置 ssh key 登录,因为后面用的到。
安装
1 | $ yum install nginx -y |
启动
1 | $ systemctl start nginx |
然后浏览器访问:
1 | $ curl http://your_public_ip |
如果一切正常,说明 nginx 启动正常。接下来把 nginx 添加到系统启动项随开机启动:
1 | $ systemctl enable nginx.service |
博客采用的是 hexo 生成的静态博客,所以只需要把博客仓库克隆下来就行:
安装 git
1 | $ yum install git -y |
克隆博客网站源码到某个目录:
1 | $ git clone https://github.com/smaugx/smaugx.github.io.git /root/ |
设置 nginx 配置文件中 80 端口的 root 为博客源码的目录:
1 | # nginx.conf |
重启 nginx:
1 | $ systemctl restart nginx |
验证博客是否正常:
1 | $ curl http://your_public_ip |
正常能看到博客的主页了。
接下来是把域名 rebootcat.com
解析到这台机器上, 如下:
1 | 主机|类型|线路|记录值|MX优先级|TTL|备注|状态 |
解析生效之后,验证是否成功:
1 | $ curl http://rebootcat.com |
全程参考这篇博文:
Linux CentOS 7 下 Nginx 安装使用 Let’ s Encrypt 证书的完整过程
这篇文章已经写的很清楚了,照着操作就行。
设置完成应该就能使用 https 访问了:
1 | $ curl https://rebootcat.com |
我还把 loveyxq.online 也解析到了这台机器上,这是我另外一个博客,给我女朋友用的一个。
nginx 的配置见后文。
经过了上面的步骤,博客已经算是迁移完成了,不过每次更新博客能否直接部署道这台机器上呢?
答案是能的,而且方法很多种。我采用的是 github 自家的持续部署工具 Github Actions.
如上图所示,分别在 Secrets
项添加 3 个变量:
注意,优于 github action 会使用上面的 sshkey 登录并推送博客,所以为了安全,建议单独生成用户,并设置单独的目录以及权限,只允许访问 nginx root path.
在博客网站源码仓库创建文件:.github/workflows/deploy.yml
,内容如下:
1 | name: Deploy site files |
上述文件记得 push 到远端仓库。然后你可以随便修改一下博客源码并且 push 到远端,正常的话应该能看到如下的输出:
重点来了,上面两部其实是经过了 hexo deploy
的踩坑的。为啥?
由于 hexo generate
默认会忽略隐藏文件,所以生成的网站源码就会忽略 .github/workflows/deploy.yml
,所以要设置一下博客根目录的 _config.yml
:
1 |
|
很重要!!!
OK,现在你可以放心大胆的使用 hexo generate
来生成博客源码了,但是当你使用 hexo deploy
的时候问题又来了, hexo deploy
默认也是忽略隐藏文件的,而且好像上面那个配置对 hexo deploy
无效。
搜索了很多,没有找到针对 hexo deploy
如何避免忽略隐藏文件的解决方案,于是探索了一下:
1 | $ hexo deploy |
可以看到,上面执行 hexo deploy
命令后的输出有一个 “.deploy_git folder”,看了一下真有这个隐藏目录,想必 hexo deploy
是把 public
目录与较旧的(上一次发布的)目录 .deploy_git
做比较,然后增量上传文件。
所以我直接把 .github/workflows/deploy.yml
拷贝到了 .deploy_git
目录,然后执行 hexo deploy
成功。
哈哈哈!!!
所以记住,如果后期修改了这个 deploy.yml ,需要手动拷贝一下,但是基本上不会再动这个文件了。
到这里,基本上就解决了利用github actions 自动化部署博客的问题了。
实测 push 仓库后到服务器上的网站源码成功替换时间很快,大概一分钟左右,Good!
上面的一切搞定后,体验了一天访问我的博客 https://rebootcat.com,使用 chrome 控制台发现 ssl 握手时间很慢,第一次访问基本都要 3 ~ 4 s左右,无法忍受,再次访问就快了。
所以网上搜索了下关于 Let’s Encrypt 的优化,找到了一些解决方案以及 nginx 的配置优化等:
1 | # config file name: /etc/nginx/nginx.conf |
重启 nginx 之后,再次使用 chrome 无恒模式打开控制台看一下访问速度,是不是有所好转。
或者直接使用 curl 命令:
1 | $ curl -X GET -w '\n\n time_namelookup: %{time_namelookup} |
输出如下:
1 | time_namelookup: 0.005120 |
上面的 time_appconnect
减去 time_connect
的耗时就是 ssl 握手的耗时 0.2349s,比之前好了很多。
OK,到这里算是把博客正式的迁移到腾讯云香港服务器上了,以后就一直打算用自己的服务器托管博客了。
使用 GitHub Actions 实现博客自动化部署
提高https载入速度,记一次nginx升级优化
Blog:
2020-11-10 于杭州
By 史矛革
观察到一台机器上的内存使用量在程序启动之后,持续增长,中间没有出现内存恢复。怀疑是不是出现了内存泄露的问题?
然后使用相关的内存分析工具进行了分析:
上述的分析结果均不能很肯定的得出是否内存泄露的结论。那么问题可能出现在哪里呢?
程序采用 c++ 编写,大量使用了智能指针以及 new/delete,难道内存没有成功释放?亦或是内存释放有什么条件?于是开始怀疑 free 是不是真的释放了内存?
既然怀疑 free 是不是真的释放了内存,此处的释放,是指程序内存占用下降,内存归还给操作系统,那么直接写一个简单的例子进行验证一下。
attention:
测试前,先关闭 swap:
1 | # swapoff -a |
步骤如下:
上代码:
1 |
|
编译:
1 | g++ mem_test.cc -o mem_test -std=c++11 |
可以通过参数控制内存分配的大小,默认 100Byte:
1 | ./mem_test 100 |
过程就省略了,直接上观察结果:
以上测试反应出在不同的情况下, free 的行为有差异,但也说明,调用 free 之后内存是能够立即被释放给操作系统的(只不过有条件)。
那为什么会出现调用 free 之后内存没有被释放(至少看起来是)的情况呢?
代码不变,还是上面的代码,只不过现在启动两个同样的程序:
上述有一个条件假设:
total mem: 4G,实际情况可以调整代码里分配内存的总量
过程也省略,直接上观察结果:
上面的结果和测试1 的结果是吻合的,这能肯定的说明出现 OOM 的场景下,第一个进程的内存虽然完全释放了,但是内存依然被该进程持有,操作系统无法把这部分已经调用 free 的内存重新分配给其他的进程(第二个进程)。
稍微调整一下上面的代码,分配释放的操作进行两次,也就是上面的 test()
函数调用 2 次。
另外本次使用 valgrind(massif) 进行分析,此次单次内存分配大小为 100 Byte,也就是上面出现无法释放内存的参数。
1 | int main() { |
使用 valgrind 进行分析:
1 | valgrind -v --tool=massif --detailed-freq=2 --time-unit=B --main-stacksize=24000000 --max-stackframe=24000000 --threshold=0.1 --massif-out-file=./massif.out ./mem_test |
生成的文件 massif.out
使用 massif-visualizer
处理之后得到如下图:
上图就是内存分配的情况,从图中可以很明显的看到在第一次调用 test()
函数时,内存随着分配而增长,随着释放而下降;第二次调用 test()
函数也是同样的情况。
那这幅图能说明什么呢?
第一次调用 test()
后,按照测试2 的情况,内存虽然被释放了,但是内存依然被进程持有,那么不应该出现内存下降的情况,但是从图中看,确实是下降到接近 0 了,那么可以得出一个结论:
test()
至少是没有内存泄露的,即分配的内存,都被释放了(至少标记过释放),也就是没有出现野指针等内存泄露的情况。
那么问题就在于,既然没有内存泄露,那为何内存依然被进程持有?不是已经调用 free 了吗?
glibc malloc 底层调用的是 ptmalloc,这里就不深入 malloc/free 的实现细节了,网上可以找到很多资料。
下图是 32 位程序的虚拟内存空间分布图
向操作系统申请内存涉及到两个系统调用 sbrk 以及 mmap。关于这两个系统调用的区别可以大致这么理解:
也就是说,ptmalloc 为了性能考虑,采用了两种内存分配策略,也就是管理了两种不同分配方式的堆内存。在分配内存小于一定值时就优先在 ptmalloc 维护的内存池里进行分配,这样避免了直接向操作系统分配内存,减少系统调用次数;如果内存大于一定值时,就直接向操作系统申请内存,并且这段内存在释放之后立即归还操作系统;
这也就能解释上面的几个测试里,当单次分配的内存大小较大时,内存释放后进程内存占用快速下降到 0%;当单次分配的内存大小较小时,内存释放后其实没有归还给操作系统,二是被 ptmalloc 重新回收了,放到了内存池里进行循环利用,所以看到进程内存依然保持较高的占用;
另外关于 ptmalloc 对内存池的管理比较复杂,这里推荐一篇不错的文章可以深度阅读:
到这里,其实就已经比较明确了,free 之后内存释放情况其实是跟分配的大小有关系的,并且随着程序的运行,内存的持续分配和释放,ptmalloc 的内存池应该能稳定在一定的值,从外面来看,进程的内存占用应该能动态稳定下来。
ptmalloc 的两套分配策略各有优劣,使用内存池可以提高内存分配效率,但是可能出现内存暴涨的情况,但是最终会稳定在一定的值;使用 mmap 的方式分配内存不会出现内存暴涨的情况,释放完之后理解归还操作系统,但降低了内存分配的效率。
根据上面的讨论,如果想要控制 malloc 的内存分配行为,那么其实是有办法做到的。
我们可以通过下面这个函数来实现:
1 | int mallopt(int param, int value); |
https://man7.org/linux/man-pages/man3/mallopt.3.html
可以调整 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个,关闭 mmap 分配阈值动态调整机制。
比如上面的测试1,当单次分配的内存 100 Byte 时,内存释放之后进程内存占用依然较高的情况就能解决:
1 | int main() { |
在 main 函数开始加上上面的两句,调整 mmap 收缩阈值以及内存分配阈值。重新编译运行,发现即使单次分配 100 Byte,内存释放后,进程内存占用也快速下降到 0%。
补充:
1 | int malloc_trim(size_t pad); |
可以触发 ptmalloc 对内存的紧缩,即归还一部分内存给操作系统。
进程内存占用较高的情况不一定是内存泄露造成的,可以通过长时间观察内存占用是否能稳定下来进行判断,如果内存占用能实现动态稳定,那么多半程序是没有内存泄露的。
但是如果内存占用过高,对其他的进程产生了干扰,那么可以适当的调整一下 malloc 的参数,控制 malloc 的行为,避免 glibc 内存池过大,影响其他进程的运行。
Blog:
2020-11-05 于杭州
By 史矛革
上一篇博文 模仿nginx修改进程名 中提到了一种修改进程名的方法,就像 nginx 一样,给不同进程命名为 master 以及 worker 等。那么能不能把新进程名设置为空字符串呢?如果能,又会有哪些应用场景呢?
答案可能是能的,设置新进程的名字为空,通常用来隐藏进程,用于攻击或者反攻击。
上一篇博文 模仿nginx修改进程名 文章末尾提到了 prctl
这个函数,它也可以用来修改进程名。
只不过如果单单使用 prctl 来修改进程名的话,使用 ps 或者 top 等工具看到的可能还是原来的名字。
源代码可以在我的 github 找到:
https://github.com/smaugx/setproctitle/blob/main/hidden_process/prctl_main.cc
1 |
|
编译运行:
1 |
然后我们查看一下进程的名字:
1 | # ps -ef |grep prctl |
可以看到 ps 看到的进程名依然是 prctl_main
而不是 prctl_new_name
。那么 prctl
函数到底修改了哪里呢? ps 命令又是从哪里读取的进程名呢?
linux 上一切皆文件,启动一个进程,就会在系统的 /proc
这个虚拟文件系统下创建这个进程相关的文件夹,里面记录了这个进程的数据。
1 | # ls /proc/20758 |
关注一下这两个虚拟文件:
1 | # cat /proc/20758/cmdline |
以及
1 | # cat /proc/20758/status |
细心的同学应该发现上面的不一致了吧, /proc/<pid>/cmdline 这个文件记录的进程名是 prctl_main
,而 /proc/<pid>/status 中 Name 值记录的进程名是 prctl_new_name
。而 ps 命令正好是读取了 cmdline 这个文件,导致即便使用 prctl 修改了进程名,但 ps 依然看到的是老的进程名。
另外要注意,prctl() 这个函数有个限制,新进程的名字长度不能超过 16 字节(包括最后的 ‘\0’),详见手册:
https://man7.org/linux/man-pages/man2/prctl.2.html
上面的分析看到,不论是修改 argv[0] 还是使用 prctl,均有其局限性,那么通常可以结合两者来进行。
源码可以在我的 github 找到:
https://github.com/smaugx/setproctitle/blob/main/hidden_process/hidden_main.cc
1 |
|
编译运行:
1 | # ps -ef |grep hidd |
可以看到,无论是通过 ps 命令还是直接查看 /proc/<pid>/ 下的文件的方式,均能看到修改后的名字: hidden_main_new
。
经过上一步,已经可以完美的修改进程名了,那么再进一步,如何隐藏进程呢?
1 | const char *new_title = ""; |
只需要修改上述的一行代码,重新编译即可,然后用 ps 或者 top 看一下,能不能找到这个进程:
1 | # ps -ef |grep hidden |
可以看到 ps 无法找到 hidden* 相关的进程,那么 top 呢?
1 | top - 18:01:06 up 16 days, 4:16, 9 users, load average: 0.00, 0.01, 0.05 |
运行 top 命令,并且以 pid 倒叙排序,注意第四行的进程,可以看到 COMMAND 为空,这个进程就是刚才的这个进程,但是看不到进程名了,达到了简单的、初级的隐藏进程的目的。
上述相关代码均可以在我的 github 找到:
https://github.com/smaugx/setproctitle/tree/main/hidden_process
上面的讨论可以看到,能实现初级的,简单的进程隐藏,但是使用 top 命令还是能看到这个无名进程,那么这点改怎么解决呢?
这里就不展开了,我没有这方面的经验。不过通常来说有两种办法:
Blog:
2020-10-25 于杭州
By 史矛革
使用 nginx 的过程中,我们经常看到 nginx 的进程名是不同的,如下:
1 | $ ps -ef |grep nginx |
可以看到 nginx 的进程名是不同的,那么它是怎么做到的呢?
首先来看一下 C 语言中的 main 函数的定义:
1 | int main(int argc, char *argv[]); |
这个应该大家都是比较熟悉的,argc 表示命令行参数个数, argv 保存了各个命令行参数的内容。其中 argv[0]
表示的是进程的名字,这就是修改进程名的关键点所在。
只需要修改 argv[0] 的值即可完成修改进程名。
下面以程序员经典入门代码为例说明:
1 | // filename: hello_world_setproctitle.cc |
编译运行:
1 | g++ hello_world_setproctitle.cc -o hello_world_setproctitle |
查看一下进程名:
1 | # ps -ef |grep hello_world |
可以看到进程名是 hello_world_setproctitle
,接下来我们修改一下 argv[0] 的值,代码如下:
1 | // filename: hello_world_setproctitle.cc |
编译运行之后,查看进程名:
1 | # ps -ef |grep hello_world |
可以看到进程名已经修改为 new_new_hello_world_setproctitle
了。
是不是很简单?
不过上面的代码是有一定的风险的,如果新的进程名超过了原来 argv[0] 的长度,就可能会影响到后面的 environ 的内容。
C 语言中 main 函数的定义还有一个:
1 | int main(int argc, char *argv[], char *envp[]); |
这个版本提供了第三个参数,大多数 Unix 系统支持,但是 POSIX.1 不建议这么做,如果要访问环境变量建议使用 getenv
和 putenv
接口。这里就不展开讲了。
envp 这个参数表示环境变量,每一个进程都有与之相关的环境变量,其中每个字符串都以(name=value)形式定义,并且 envp 的地址紧跟在 argv 之后。
接下来我们打印一下 envp 这个参数的值,基于上面的代码,简单修改一下:
1 | // filename: hello_world_setproctitle.cc |
上面的代码同时也打印了每个参数的地址以及长度,编译并执行:
1 | # ./hello_world_setproctitle 1 22 |
可以看到上述各个 argv 的值以及 envp 参数的内容。
这里需要重点注意一下最后一个 argv[2] 参数以及第一个 envp[0] 参数的地址:
1 | mem:0x7ffc84cf7561 len:2 argv[2]: 22 |
0x7ffc84cf7564 正好等于 0x7ffc84cf7561 + 3 (argv[2] 的长度加上最后一个 ‘\0’)。可以多试几次,不同的参数个数验证下这个。
所以 environ 的地址(envp[0] 的地址)是紧跟在 argv 后面的,那么前面提到的如果当新的进程名长度超出 argv 的长度后,可能就会覆盖后面的 environ 内容,导致其他一些问题。
修改如上代码:
1 | // filename: hello_world_setproctitle.cc |
编译运行:
1 | # ./hello_world_setproctitle |
可以看到,上面打印出来的 envp[0], envp[1].. envp[5] 都已经被覆盖了。
所以,通过 argv[0] 修改进程名,如果新进程名过长,需要考虑到 envp 的覆盖问题,通常做法是把 envp 的内容先保存,然后指向新的内存,再把保存的环境变量复制到新的内存,然后再去修改 argv[0]。
可以参考 nginx 的源码: https://github.com/nginx/nginx/blob/master/src/os/unix/ngx_setproctitle.c
下面直接上源码,源码可以在我的 github 找到:
https://github.com/smaugx/setproctitle
setproctitle.h
1 | // author: smaug |
setproctitle.cc
1 | // author: smaug |
编译运行:
1 | # sh build.sh |
可以看到上述的命令行参数以及环境变量在父子进程中都是正确的,查看一下进程名:
1 | # ps -ef |grep setproc |
上述代码可以完美的修改进程名,但是如果你使用查看进程信息可能还会看到旧的进程名:
1 | # ps -ef |grep setproc |
这个时候可以结合 prctl 使用:
1 | prctl(PR_SET_NAME, new_name); |
具体可以查看相关资料。
Blog:
2020-10-25 于杭州
By 史矛革
上一篇博文 epoll原理深入分析 详细分析了 epoll 底层的实现原理,如果对 epoll 原理有模糊的建议先看一下这篇文章。那么本文就开始用 epoll 实现一个简单的 tcp server/client。
本文基于我的 github: https://github.com/smaugx/epoll_examples。
1 |
|
epoll 编程基本是按照上面的范式进行的,这里要注意的是上面的反应的只是单进程或者单线程的情况。
如果涉及到多线程或者多进程,那么通常来说会在 listen() 创建完成之后,创建多线程或者多进程,然后再操作 epoll.
1 | int listenfd = ::socket(); |
同理,多线程版本也是一样,把上面的 fork() 替换成 thread 创建即可。
也就是 listenfd 被添加到了多个进程或者多个线程中,提高吞吐量。这就是基本的 epoll 多进程或者多线程编程范式。
但本文就先讨论单进程(单线程)版本的 epoll 实现。
先上代码:
1 |
|
代码看起来有点多,不过仔细分析下,其实也比较容易掌握。
核心的类是 EpollTcpServer,创建一个 EpllTcpServer 实例:
1 | auto epoll_server = std::make_shared<EpollTcpServer>(local_ip, local_port); |
注册一个收包处理回调函数:
1 |
|
启动 tcp server:
1 | epoll_server->Start(); |
是不是很简单?至于 Start() 函数内部,其实实现的就是 epoll 编程范式的细节。
代码细节应该比较好理解的,可以参考 https://github.com/smaugx/epoll_examples/blob/master/README.md
1 |
|
代码和 server 端代码基本上很类似,除了没有 accept() 的处理,这里就不分析了。
上面的代码是基于 ET模式(边缘触发模式)实现的。
源代码可以直接在我的 github: https://github.com/smaugx/epoll_examples 找到;
或者有兴趣的话也可以直接看我的另外一个项目 https://github.com/smaugx/mux,基于 epoll 实现的高并发网络库。
Blog:
2020-09-26 于杭州
By 史矛革
上一篇博文 Epoll原理深入分析 在讲 accept 事件 的时候提到过 惊群效应,本文就分析一下惊群效应的原因以及解决方法。
惊群效应就是多个进程(线程)阻塞等待同一件事情(资源)上,当事件发生(资源可用)时,操作系统可能会唤醒所有等待这个事件(资源)的进程(线程),但是最终却只有一个进程(线程)成功获取该事件(资源),而其他进程(线程)获取失败,只能重新阻塞等待事件(资源)可用,但是这就造成了额外的性能损失。这种现象就称为惊群效应。
如果细心的你可能会问,为什么操作系统要同时唤醒多个进程呢?只唤醒一个不行吗?这样不就没有这种性能损失了吗?
确实如此,操作系统也想只唤醒一个进程,但是它做不到啊,因为它也不知道该唤醒哪一个,只好把所有等待在这件事情(资源)的进程都一起唤醒了。
那有没有办法解决呢?当然有,我们后面再说。
惊群效应会造成多个进程白白唤醒而啥也做不了。那么唤醒进程损失了啥?这就涉及到进程上下文的概念。
进程上下文包括了进程的虚拟内存,栈,全局变量等用户空间的资源,还包括内核堆栈,寄存器等内核空间的状态。
所以进程上下文切换就首先需要保存用户态资源以及内核态资源,然后再去加载下一个进程,首先是加载了下一个进程的内核态,然后再去刷新进程的用户态空间。
然而 CPU 保存进程的用户态以及内核态资源,再去加载下一个进程的内核态和用户态是有代价的,也是耗时的,每次可能在几十纳秒到数微妙的时间,如果频繁发生进程切换,那么 CPU 将有大量的时间浪费在不断保存资源,加载资源,刷新资源等事情上,造成性能的浪费。
所以惊群效应会造成多个进程切换,造成性能损失。
为了直观的了解惊群效应是什么,我们采用 mux 项目当中的 echo_server
为例说明:
https://github.com/smaugx/mux/tree/master/demo/echo
编译命令详见项目说明文档。编译之后得到:
1 | echo_server echo_client |
我们在 echo_server
上开启 8 个 epoll 线程,观察当有新连接过来时是否这 8 个线程(epoll) 都被唤醒了。
首先,运行:
1 | ./echo_server |
再运行:
1 | ./echo_client (或者直接用 nc 127.0.0.1 6666) |
我们观察的 echo_server
的 log 如下:
1 | smaug@smaug-VirtualBox:~/workspace/mux/cbuild/bin/log$ tail -f echo_server.log |grep accept |
从上可以看到,我们总共有 8 个线程,其中只有 3号线程(epoll)被唤醒并且成功获取了 accept 事件,其他线程均 accept error
。
多次测试会有不同的线程获取 accept
事件,但是只有一个能够成功获取,其余的全部失败。
为了更加直观的感受惊群造成的性能损失,我们做一个并发压测:
https://github.com/smaugx/mux/tree/master/demo/bench
编译上面的代码得到:
1 | bench_client_accept bench_server |
首先,启动:
1 | ./bench_server 127.0.0.1 10000 > /dev/null 2>&1 & |
再启动:
1 | ./bench_client_accept 127.0.0.1 10000 30000 100 > /dev/null 2>&1 & |
使用之前的博文 一键采集cpu生成火焰图 中的脚本采集火焰图如下:
可以看到途中 __libc_accept
占据了 31.7% 的 cpu,可以说是很高很高了。
由此可以看到惊群效应带来的性能损失有多少了吧。
惊群的类型根据 socket 编程采用的不同方式有关。
传统的多进程 socket 编程,通常是 listen()
之后创建多个 worker 进程进行 accept,那么这里就会造成当有新连接过来时,多个 worker 同时去 accept 的情况,但最终只有一个 worker 进程成功 accept,其余的全部失败。
1 | ... |
此种情况下的惊群称为 accept惊群效应,这在 linux 内核2.6以后就已经解决了,所以通常情况下讨论的惊群通常不是 accept惊群,而是 epoll惊群。
epoll 的编程模型一般有两种,我们姑且先分别称为 版本1 和 版本2 吧:
1 | int listenfd = ::socket(); |
或者 fork()
在 epoll_create()
之前:
1 |
|
虽然上述两个版本均能实现多进程下的 epoll 编程,且都存在惊群效应,但版本1,也就是 fork()
在 epoll_create()
之后会造成事件混乱。
因为多个进程等待的是同一个 epollfd,就有可能造成同一个连接,worker A 获取了 accept 事件,成功建立了连接,但是后续的读事件被 worker B 获取了,造成连接和读写事件不匹配的情况。
所以通常,我们采用的是版本2,也就是 fork()
在 epoll_create()
之前,那么多个子进程其实是拥有各自不同的 epollfd,只不过对于 listenfd 而言,都被添加到了各个子进程的 epoll instance 中。
当 listenfd 上有事件触发时(listenfd 上的事件自然是 accept 事件),由于有多个子进程的 epoll instance 上都有 listenfd。
根据之前的博文 Epoll原理深入分析,当某个 fd 上有事件后,内核会把这个 fd 拷贝到 epoll 的就绪链表中,并且唤醒进程,通知应用层使用 epoll_wait
来处理事件。
所以由于多个子进程都把 listenfd 插入到了自己的 epoll instance 中,那么当 listenfd 上有事件触发时,自然这些子进程都会被唤醒了。但是最终只有一个子进程成功获取 accept 事件,其余的均失败。这就是惊群效应,详见上面的惊群效应测试。
上面提到,针对传统 accept 惊群,linux 在内核 2.6 以后就解决了,内核通过引入一个 WQ_FLAG_EXCLUSIVE
标志位,告诉内核排他性的唤醒,即当 socket 上有事件触发时,对于等待队列中的进程,如果这些进程没有 WQ_FLAG_EXCLUSIVE
这个标志位,那么就通通唤醒,如果有 WQ_FLAG_EXCLUSIVE
这个标志位,那么唤醒第一个有这个标志位的进程则结束。这样,就解决了传统 accept 惊群问题。
epoll 的惊群有两种解决办法。
linux 在内核 3.9 版本引入了一个 socket 选项 SO_REUSEPORT
用来支持多个进程监听在同一个端口上,内核负责事件触发的负载均衡。
创建一个 listen socket,需要 {protocol, src_addr, src_port} 三元组,3.9 版本之前,内核不允许出现多个进程使用同样的三元组创建 socket,会出现 Address already in use
错误。
但是,通过引入 SO_REUSEPORT
以及 SO_REUSEADDR
,内核允许多个进程使用同样的三元组创建 socket,内核负责负载均衡。
ok,明白了这个原理,对于解决 epoll 的惊群问题,还需要稍微修改一下编程的模型,我们姑且成为 版本3 吧:
1 |
|
可以对比一下和上面的编程模型有何不同,其实区别在创建 listenfd 被移到了 fork()
之后,程序启动即创建多个进程,然后进程内部再创建 listenfd 以及 epollfd,等等后续一系列操作。
另外要注意在 socket()
之后,使用 setsockopt()
设置了 SO_REUSEPORT
选项。
那么内核是如何做负载均衡的呢?
其实很简单,每一个新的连接都具有 socket 五元组 {protocol, src_addr, src_port, dst_addr, dst_port},那么用这个五元组哈希一下映射到不同的进程,那么就唤醒这个进程。
SO_REUSEPORT
由于采用的是哈希的方式,内核并不知道多个等待进程是否空闲,但哈希的方式依然可能还会分配到这个进程,此时这个新的 accept 就可能会超时不被处理。
linux 在内核 4.5 引入了 EPOLLEXCLUSIVE 这个标志位用来解决 epoll 的惊群。当我们使用 epoll_ctl()
往进程的 epoll instance 中插入一个需要监听的 fd 时,如果显示的传入 EPOLLEXCLUSIVE,那么内核会排他性的进行唤醒。
当然这里通常只需要对多个子进程共同监听的 listenfd 设置 EPOLLEXCLUSIVE
标志位。注意,这里的 epoll 编程模型要采用上面的版本2。
和解决传统 accept惊群 类似的方式,但是区别是内核可能会唤醒不只一个进程(虽然解决了不全部唤醒的问题),详见:
https://man7.org/linux/man-pages/man2/epoll_ctl.2.html
1 | When a wakeup event occurs and multiple epoll file descriptors |
注意上面的 one or more
OK,到这里基本上把惊群效应的原理以及带来的问题,以及解决方法都讲清楚了,本来还想做一个加上了 EPOLLEXCLUSIVE,再采集一下火焰图和之前的进行一下对比,但是不知道是我的方式不对还是什么原因,加上 EPOLLEXCLUSIVE 标志后,连接压力测试就池池上不去,一直出现 connection timeout
的问题。
算了,这个以后再研究下为啥。
如果上文发现有什么不对的地方,欢迎指正。
Blog:
2020-09-26 于杭州
By 史矛革
想必能搜到这篇文章的,应该对 select/poll 有一些了解和认识,一般说 epoll 都会与 select/poll 进行一些对比,select、poll 和 epoll 都是一种 IO 多路复用机制。
select 的问题在于描述符的限制,能监控的文件描述符最大为 FD_SETSIZE,对于连接数很多的场景就无法满足;
另外select 还有一个问题是,每次调用 select 都需要从用户空间把描述符集合拷贝到内核空间,当描述符集合变大之后,用户空间和内核空间的内存拷贝会导致效率低下;
另外每次调用 select 都需要在内核线性遍历文件描述符的集合,当描述符增多,效率低下。
由于 select 存在上面的问题,于是 poll 被提了出来,它能解决 select 对文件描述符数量有限制的问题,但是依然不能解决线性遍历以及用户空间和内核空间的低效数据拷贝问题。
select/poll 在互联网早期应该是没什么问题的,因为没有很多的互联网服务,也没有很多的客户端,但是随着互联网的发展,C10K 等问题的出现,select/poll 已经不能满足要求了,这个时候 epoll 上场了。
epoll 是 linux 内核 2.6 之后支持的,epoll 同 select/poll 一样,也是 IO 多路复用的一种机制,不过它避免了 select/poll 的缺点。下面详细讲解一下 epoll 反应堆的原理。
要完整描述 epoll 的原理,需要涉及到内核、网卡、中断、软中断、协议栈、套接字等知识,本文尽量从比较全面的角度来分析 epoll 的原理。
上面其实讨论了 select/poll 几个缺点,针对这几个缺点,就需要解决以下几件事:
针对第一点:如何突破文件描述符数量的限制,其实 poll 已经解决了,poll 使用的是链表的方式管理 socket 描述符,但问题是不够高效,如果有百万级别的连接需要管理,如何快速的插入和删除就变得很重要,于是 epoll 采用了红黑树的方式进行管理,这样能保证在添加 socket 和删除 socket 时,有 O(log(n)) 的复杂度。
针对第二点:如何避免用户态和内核态对文件描述符集合的拷贝,其实对于 select 来说,由于这个集合是保存在用户态的,所以当调用 select 时需要屡次的把这个描述符集合拷贝到内核空间。所以如果要解决这个问题,可以直接把这个集合放在内核空间进行管理。没错,epoll 就是这样做的,epoll 在内核空间创建了一颗红黑树,应用程序直接把需要监控的 socket 对象添加到这棵树上,直接从用户态到内核态了,而且后续也不需要再次拷贝了。
针对第三点:socket就绪后,如何避免内核线性遍历文件描述符集合,这个问题就会比较复杂,要完整理解就得涉及到内核收包到应用层的整个过程。这里先简单讲一下,与 select 不同,epoll 使用了一个双向链表来保存就绪的 socket,这样当活跃连接数不多的情况下,应用程序只需要遍历这个就绪链表就行了,而 select 没有这样一个用来存储就绪 socket 的东西,导致每次需要线性遍历所有socket,以确定是哪个或者哪几个 socket 就绪了。这里需要注意的是,这个就绪链表保存活跃链接,数量是较少的,也需要从内核空间拷贝到用户空间。
从上面 3 点可以看到 epoll 的几个特点:
比较精炼的话可能反而理解起来不容易,那么接下来深入分析一下 epoll 的原理。
如果要深入分析 epoll 的原理,那么可能需要结合到 epoll 的 api 来进行阐述。epoll api 较少,使用起来相对比较简单。
1 | #include <sys/epoll.h> |
epoll 涉及到的 api 其实比较简单,掌握了这几个 api 其实就已经能够快速编写基于 epoll 的 tcp/udp socket 程序。可以参考:
https://github.com/smaugx/epoll_examples.git
接下来结合上面的几个 api 来详细分析以下背后的原理。
前面提到,epoll 是一种 IO 多路复用机制,应用程序可以同时监控多个 socket,那么如何来存储和管理这些 socket 呢,epoll 使用的是一颗红黑树,可以随意的往这棵树上添加节点和删除节点(节点是一个结构体,包括 socket fd)。
我们使用:
1 | int epoll_create(int size); |
创建一个 epoll instance,实际上是创建了一个 eventpoll 实例,包含了红黑树以及一个双向链表。
可以直接查看 linux 源码:https://github.com/torvalds/linux/blob/master/fs/eventpoll.c#L181
1 | /* |
这个 eventpoll 实例是直接位于内核空间的。红黑树的叶子节点都是 epitem 结构体:
可以直接查看 linux 源码: https://github.com/torvalds/linux/blob/master/fs/eventpoll.c#L137
1 | struct epitem { |
关于各项的解释,注释里已经说的比较清楚了。我们关心的应该是,当往这棵红黑树上添加、删除、修改节点的时候,我们从(用户态)程序代码中能操作的是一个 fd,即一个 socket 对应的 file descriptor,所以一个 epitem 实例与一个 socket fd 一一对应。
另外还需要注意到的是 rdllink 这个变量,这个指向了上一步创建的 evnetpoll 实例中的成员变量 rdllist,也就是那个就绪链表。这里很重要,注意留意,后面会讲到。
当然,我们还需要关注的是 event 这个变量,代表了我们针对这个 socket fd 关心的事件,比如 EPOLLIN、EPOLLOUT。
通过上述的讲解应该大致明白了,当我们使用 socket() 或者 accept() 得到一个 socket fd 时,我们添加到这棵红黑树上的是一个结构体,与这个 socket fd 一一对应。
那么修改和删除呢?
也是类似的过程,使用 ffd 变量作为红黑树比较的 key,能够快速的查找和插入。具体我们使用的是:
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
上面过程已经把我们关心的 socket 添加到 epoll instance 中了,那么当某个 socket 有事件触发时,epoll 是如何感知并通知(用户态)应用程序呢?
要完整的回答这个问题,会涉及到比较多的知识。不过为了了解 epoll 的原理,有一些知识需要提前了解。
当一个包从网卡进来之后,是如何走到应用程序的呢?中间经过了哪些步骤呢?(本文会讲的比较简略一点)
包从硬件网卡(NIC) 上进来之后,会触发一个中断,告诉 cpu 网卡上有包过来了,需要处理,同时通过 DMA(direct memory access) 的方式把包存放到内存的某个地方,这块内存通常称为 ring buffer,是网卡驱动程序初始化时候分配的。
中断 的原理学过微机原理的应该都知道,表示处理器接收到来自硬件或者软件的信号,提示产生了某件事情,需要处理。
当 cpu 收到这个中断后,会调用中断处理程序,这里的中断处理程序就是网卡驱动程序,因为网络硬件设备网卡需要驱动才能工作。网卡驱动会先关闭网卡上的中断请求,表示已经知晓网卡上有包进来的事情,同时也避免在处理过程中网卡再次触发中断,干扰或者降低处理性能。驱动程序启动软中断,继续处理数据包。
然后 CPU 激活 NAPI 子系统,由 NAPI 子系统来处理由网卡放到内存的数据包。经过一些列内核代码,最终数据包来到内核协议栈。内核协议栈也就是 IP 层以及传输层。经过 IP 层之后,数据包到达传输层,内核根据数据包里面的 {src_ip:src_port, dst_ip:dst_port}
找到相应的 socket。
为了性能,内核应该是有一个四元组和 socket 句柄的一一映射关系。(这里不太确定,不过原理应该是类似的)
然后把数据包放到这个 socket 的接收队列(接收缓冲区)中,准备通知应用程序,socket 就绪。
上面比较简略的描述了一个数据包从网卡到内核协议栈,再到 socket 的接收缓冲区的步骤,描述的比较简略,不影响对 epoll 原理的理解,这里只需要有这个概念就行。
那么当 socket 就绪后,也就是数据包被放到 socket 的接收缓冲区后,如何通知应用程序呢?这里用到的是等待队列,也就是 wait queue。关于 wait queue 的应用,在 linux 内核代码里有很多,具体可以看一下 wait queue 的定义:
https://github.com/torvalds/linux/blob/master/include/linux/wait.h
当我们通过 socket() 以及 accept() 获取到一个 socket 对象时,这个 socket 对象到底有哪些东西呢?
可以直接参考 https://github.com/torvalds/linux/blob/master/include/linux/net.h#L113
1 | /** |
可以看到,一个 socket 实例包含了一个 file 的指针,以及一个 socket_wq 变量。其中 socket_wq 中的 wait 表示等待队列,fasync_list 表示异步等待队列。
那么等待队列和异步等待队列中有什么呢?大致来说,等待队列和异步等待队列中存放的是关注这个 socket 上的事件的进程。区别是等待队列中的进程会处于阻塞状态,处于异步等待队列中的进程不会阻塞。
阻塞的概念学过操作系统的应该知道,阻塞是进程的一种状态,表示一个进程正在等待某件事情的发生而暂时停止运行;另外还有运行状态以及就绪状态。
当 socket 就绪后(接收缓冲区有数据),那么就会 wake up 等待队列中的进程,通知进程 socket 上有事件,可以开始处理了。
至此,一个数据包从网卡最终达到应用程序内部了。
再简单总结一下收包以及触发的过程:
{src_ip:src_port, dst_ip:dst_port}
找到 socket 对象(内核维护了一份四元组和 socket 对象的一一映射表)上面其实是对内核收包以及事件触发的综合描述,涉及到 epoll 后,稍微有点差异。
上面其实提到了等待队列,每当我们创建一个 socket 后(无论是 socket()函数 还是 accept() 函数),socket 对象中会有一个进程的等待队列,表示某个或者某些进程在等待这个 socket 上的事件。
但是当我们往 epoll 红黑树上添加一个 epitem 节点(也就是一个 socket 对象,或者说一个 fd)后,实际上还会在这个 socket 对象的 wait queue 上注册一个 callback function,当这个 socket 上有事件发生后就会调用这个 callback function。这里与上面讲到的不太一样,并不会直接 wake up 一个等待进程,需要注意一下。
简单讲就是,这个 socket 在添加到这棵 epoll 树上时,会在这个 socket 的 wait queue 里注册一个回调函数,当有事件发生的时候再调用这个回调函数(而不是唤醒进程)。
下面简单贴一下 epoll 中关于注册这个回调函数的部分代码:
1 | /* |
那么这个回调函数做了什么事呢?
很简单,这个回调函数会把这个 socket 添加到创建 epoll instance 时对应的 eventpoll 实例中的就绪链表上,也就是 rdllist 上,并唤醒 epoll_wait,通知 epoll 有 socket 就绪,并且已经放到了就绪链表中,然后应用层就会来遍历这个就绪链表,并拷贝到用户空间,开始后续的事件处理(read/write)。
所以这里其实就体现出与 select 的不同, epoll 把就绪的 socket 给缓存了下来,放到一个双向链表中,这样当唤醒进程后,进程就知道哪些 socket 就绪了,而 select 是进程被唤醒后只知道有 socket 就绪,但是不知道哪些 socket 就绪,所以 select 需要遍历所有的 socket。
另外,应用程序遍历这个就绪链表,由于就绪链表是位于内核空间,所以需要拷贝到用户空间,这里要注意一下,网上很多不靠谱的文章说用了共享内存,其实不是。由于这个就绪链表的数量是相对较少的,所以由内核拷贝这个就绪链表到用户空间,这个效率是较高的。
我来来直接看一下 epoll_wait 做了什么事?epoll_wait 最终会调用到 ep_send_events_proc 这个函数,从函数名字也知道,这个函数是用来把就绪链表中的内容复制到用户空间,向应用程序通知事件。
1 | static __poll_t ep_send_events_proc(struct eventpoll *ep, struct list_head *head, |
上面可以看到,这里确确实实是从内核复制 rdllist 到用户空间,非共享内存。应用程序调用 epoll_wait 返回后,开始遍历拷贝回来的内容,处理 socket 事件。
至此,从注册一个 file descriptor(socket fd) 到 epoll 红黑树,到这个 socket 上有数据包从网卡进来,再到如何触发 epoll,再到应用程序的用户空间,由应用程序开始 read/write 事件的整个过程就理顺了。不知道大家有没有理解了?
accept 事件属于可读事件的一种,这里单独提出来讲一下,是因为编程的时候针对 accept 有一些点需要注意,这里先大致讲一下,后面会有另外的博文展开讲。
当 socket 有可读事件达到后,epoll_wait 获取到就绪的 socket,应用程序开始处理可读事件,如果这个 socket 的 fd 等于 listen() 的 fd,说明有新连接到达,(server)开始调用 accept() 处理连接。
accept() 返回的新的 socket 对象,对应与 client 的一个新的连接,应用程序需要把这个新的 socket 对象注册到 epoll 红黑树上,并且添加关心的事件(EPOLLIN/EPOLLOUT…),然后开始 epoll 循环。
另外还有一点要注意的,accept 的惊群效应。
先解释一下什么是惊群,如果一个 socket 上有多个进程在同时等待事件,当事件触发后,内核可能会唤醒多个或者所有在等待的进程,然而只会有一个进程成功获取该事件,其他进程都失败,这种情况就叫惊群,会一定程度浪费 cpu,影响性能。如果用一个例子来解释的话就是,有一个鸡群,如果往这个鸡群里丢一粒米,那么会造成所有鸡(或者大多数鸡)一起来争抢这粒米,但是最终只会有一只鸡能抢到这粒米。
对于 accept() 来说,通常我们会使用多线程或者多进程的方式来监听同一个 listen fd,此时,就很可能发生惊群效应。
关于惊群效应,此处只简单提一下概念,后面开另外的博文深入探讨下惊群效应以及解决方案。
上面深入的分析了 epoll 的底层实现原理,现在回到文章开头提到的与 select/poll 对比的几个优点,是不是能理解了呢?
简单总结一下:
到这里应该能比较透彻的理解 epoll 的原理了,接下来会继续写几篇关于 epoll 的博文(先把坑埋下):
Blog:
2020-09-26 于杭州
By 史矛革
博客通常是用来记录一些完整的文章,每篇文章有一个主题。但是我想把平日里的一些笔记也记录到我的博客里,但笔记是零散的,随时的,不是完整的一个主题。所以打算构建一个 wiki 页面,专门用来存放我的笔记,wiki 页面类似于 维基百科的形式。
我的博客采用的是 hexo 构建的,如果打算 DIY 一个类似于 维基百科 的 wiki 页面的话,对于我来说,也许有点难度,毕竟我只会写简单的网页。那么有没有现成的方案或者替代的方案呢?
答案是有的,那就是 mkdocs。
什么是 Mkdocs 呢?
MkDocs is a fast, simple and downright gorgeous static site generator that’s geared towards building project documentation. Documentation source files are written in Markdown, and configured with a single YAML configuration file. Start by reading the introduction below, then check the User Guide for more info.
mkdocs 是一个用 python 编写的静态站点生成工具,主要是用来编写项目文档,文档使用 Markdown 编写,只需要配合一个 YAML 配置文件,就能快速生成一个站点。
毫无疑问,对于我来说,它有以下几个优点:
可以先看一下 我的wiki.
可以参考官方文档:mkdocs.org,或者直接往下看:
首先安装 mkdocs:
1 | $ pip install mkdocs |
安装完成之后直接生成一个项目:
1 | $ mkdocs new mysite [23:33:49] |
看看都生成了啥:
1 | $ cd mysite |
默认生成了一个 yml 配置文件以及一个 默认的 markdown 文件。
看看 mkdocs 支持哪些命令:
1 | $ mkdocs -h [23:36:21] |
构建站点:
1 | $ mkdocs build |
然后生成了一个 site
目录:
1 | $ tree [23:37:23] |
可以看到 site 目录下就是站点的源码了,那么本地测试一下:
1 | $ mkdocs serve |
然后访问 http://127.0.0.1:8000
,能看到默认的站点了:
是不是超级超级简单?
那么这个是 mkdocs 最简单的使用,接下来分享下我的使用,经过了一些定制化,包括主题的选择,域名的绑定,站点的发布等。
我的博客使用了 github pages 进行托管(目前不是,目前已经迁移到香港虚拟空间),但是如何把上面 mkdocs 生成的站点源码和博客源码放到一起呢?
有很多方法,比如可以手动把 wiki 站点源码放到博客根目录下;
但其实 github pages 是可以支持多个站点的,不知道有没有同学还不知道?
简单来说,使用一个 github 账号,能创建一个 用户站点,格式为 <user>.github.io
,比如我的博客源码仓库: smaugx.github.io;
但是除了一个用户站点之外,还能创建任意多个 普通站点,仓库名字任意,没有要求。
也就是说一个 github 账户其实是可以创建多个博客站点的。
关于如何创建一个普通站点,可以参考 github 官方文档:创建 GitHub Pages 站点.
或者往下看。
这里以我的 wiki 为例: https://github.com/smaugx/wiki,站点效果可以直接查看我的 wiki: https://rebootcat.com/wiki。
1 在 github 上创建一个仓库,命名为 wiki 或者其他的任意名字
2 克隆我的项目: git clone https://github.com/smaugx/wiki.git
3 更改仓库 remote-url 为你刚创建的 wiki 的 github url
1 | cd wiki |
上面改成你自己的 wiki 地址(或者使用 ssh 的方式)
4 推送本地仓库 wiki 到远程 wiki
1 | git push -u origin master |
至此,你的 github 上应该有一个和我的 wiki 仓库一样的仓库了。
接下来讲一下怎么设置仓库。
5 首先去到刚创建好的 wiki 仓库 https://github.com/yourname/your-wiki
6 点击设置,往下拉到 GiHub Pages 配置项,选择 master 分支,选择 /docs 目录,然后点击 save 保存
7 上面一部之后,再次回到 Github Pages 配置项,找到下面的 Custom domain,填入你的域名或者 url 地址,比如我直接写了: http://rebootcat.com/wiki
8 不出意外,你就能正常访问了。
上面的前提当然是你已经有了个人博客,也就是已经有了一个命名为
<user>.github.io
的仓库了,不然是不会成功了,你要先创建一个这样的仓库。
上面如果顺利的话,你能看到和我的 wiki 一样的内容:
那么如何编写你自己的 wiki 文章呢?
我们回到本地的 wiki 仓库:
1 | cd wiki |
注意,我的文档都放在了 source 目录下:
1 | $ ls source |
所以你只需要删除我的 Markdown文档,把你的 Markdown 文档放到该目录,然后执行:
1 | $ python run.py |
这个脚本的功能是根据 source 目录下的 Markdown 文档,更新 yaml 站点配置文件,然后生成站点源码,然后推送站点源码到 github 上。
如果执行出错,可以自行调试一下,一般问题不大。
维基
栏这个过程就省略了。
wiki 站点搭建完毕,
Blog:
2020-09-20 于杭州
By 史矛革
关于 CDN 是什么,我想应该不用做过多的介绍,毕竟现在是一个 “云” 的时代,你至少也听说过 阿里云 或者 腾讯云 吧,当然其中就包括 CDN 业务。
CDN 的作用有很多,比如可以用来加速网站的访问,可以用来防护网站等。本篇文章讨论的就是使用 cloudflare 作为 CDN 来加速博客网站,并让博客开启 https,提升博客安全等级。
选择什么 CDN 呢?
选择 CDN,对于个人博客来说,主要考虑的还是访问速度以及价格,当然也有免费的 CDN。Cloudflare 就是一家提供免费 CDN 的公司,也是在 CDN 领域比较知名的公司。
话不多说,关于 cloudflare 的配置网上可以搜到很多文章,这里我就简单记录一下。
由于我的博客 rebootcat.com 已经迁移到香港的虚拟主机了,并且开启了 https 访问,详见博文: 迁移博客到香港虚拟空间,故我以我另外的一个博客 loveyxq.online 为例说明。
loveyxq.online 这个博客是我给我女朋友搭建的,放了一些图片之类的,之前也是托管于 github pages 上。
1 开始之前,需要限注册一个 Cloudflare 账号,这个没说的
2 注册好之后 Add site
添加你的博客域名
3 然后选择一个计划 Select a plan
,此处我们选择免费版本的(当然你也可以选择收费版),然后点击 Confirm plan
4 然后添加 DNS 记录
5 完成之后需要去到你的域名注册网站,修改 nameservers 为 cloudflare 自己的,通常是:
1 | TypeValue |
6 完成之后点击 Recheck Nameservers
来检查配置是否正确。
如上图所示,选择 Full mode
。
设置完成后需要等待一段时间,才能使用 https 的方式去访问。此处是一个坑,设置完成以后别着急,可能要等待一个小时左右(具体忘了),cloudflare 在做 ssl 验证。
实话实说,效果没有很好,毕竟免费版本的 cloudflare 给的解析节点其实不多, 如下图红框内部所示。然后也可以看到,全球各地对 loveyxq.online 的解析都是到了 cloudflare 上,已经没有 github pages 的 IP 了。
另外,使用了 cloudflare 之后,cloudflare 也会对网站的访问情况以及防御情况做统计:
关于 CDN 的介绍,以后有空再重新分享一篇吧。主要是涉及到 CDN 的安全以及源站的防护这块。
Blog:
2020-09-20 于杭州
By 史矛革
我的博客一直采用的是 github pages 来托管,中间断断续续的也没怎么管理过,偶尔写几篇博客,所以也就没怎么关心过访问速度,搜索引擎收录等问题。
不过我对博客一直还是情有独钟,我觉得像我一样的软件工程师,如果能有个人博客,并且保持一定程度的更新率还是很有必要的。
这次迁移主要考虑三个原因:
github pages 服务器位于美国,对于中文博客来说,访问还是有一些慢的,且不说 github 未来在我国很有可能被 feng,所以打算迁移到国内来。之前博客其实是有部署过双线的,国外走 github,国内走 coding,但奈何 coding 不争气,后来我干脆停了 coding 的解析。现在打算找一个付费的香港虚拟主机,一年几十块钱搞定。
另外就是由于之前已经采用了 rebootcat.com 这个域名,所以无法在 github pages 上开启 https(当然方法是有的,比如使用 cloudflare 加速,这个详见我另外一篇博文),所以这次的迁移也打算开启全站 https。
虚拟主机是什么?
虚拟主机(英语:virtual hosting)或称 共享主机(shared web hosting),又称虚拟服务器,是一种在单一主机或主机群上,实现多网域服务的方法,可以运行多个网站或服务的技术。虚拟主机之间完全独立,并可由用户自行管理,虚拟并非指不存在,而是指空间是由实体的服务器延伸而来,其硬件系统可以是基于服务器群,或者单个服务器。(来自某百科)
简单来说,虚拟主机就是你可以用来托管网站,给你一定量的存储空间,以及访问流量,还有IP 或者域名绑定等。
这里需要说明的是,你能搜到很多免费的虚拟空间,免费的我个人不太建议,免费的有很多问题这里就不细说了,况且付费的也没有很贵,一年几十块钱,当然还是有可能跑路的(手动狗头)!
如上图所示,这是我购买的虚拟主机的控制面板,提供了比较方便的中文管理面板,比如域名绑定,缓存设置,SSL 设置,FTP 管理等。
具体是哪一家,我就不说了(没有给我广告费,我的服务商看到了欢迎联系)。
由于之前是解析到 github pages 的,现在购买了虚拟主机后,会有一个新的 IP,需要重新解析域名到这个 IP 上
如上图所示,红色框里面的就是新加的两条 DNS 解析记录,黄色框里面就是之前解析到 github pages 的记录,现在我把他们全部暂停了(以防后期会用到)。
解析完成之后,等待生效,使用多地 ping 的工具去测试一下 DNS 解析是否生效了。或者你本地使用 ping 看是否生效了。
1 | $ ping rebootcat.com -c 4 [10:45:50] |
可以看到上面解析到了新的 IP 上。
我的博客是基于 hexo 搭建的,之前是直接把网站源码发布到 github pages 上了:
1 | hexo d -g |
现在需要把生成的网站源码打包上传到虚拟主机上。
hexo 生成的网站源码位于 public
目录下:
1 | zip -r blog.zip public |
然后把 blog.zip 通过面板上的 在线文件管理 上传到虚拟主机的根目录里,比如我的根目录是 /wwwroot/
,然后点击解压。
完成之后,浏览器输入网站
1 | http://rebootcat.com |
看能否正确响应。一般来说,没什么问题,如果无法访问,请联系你的虚拟主机提供商。
上面的步骤,基本上已经完成了博客迁移的大部分工作了。不过对于程序员来说,怎么能每次更新博文之后还要重复上面的步骤,甚至是需要每次用浏览器打开虚拟主机控制面板上传网站源码,那岂不是很麻烦,并且不够极客精神。
那必然是要做成自动化的方式,一个命令搞定网站更新。
其实也简单,就是利用服务商提供的 FTP 口令,使用 python 脚本自动化上传网站源码,实现自动化更新。
python 脚本可以直接从我的 github 下载:
https://github.com/smaugx/dailytools/blob/master/ftpblog.py
然后修改代码里的网站域名以及 ftp 口令,改成你自己的,修改上传的本地目录以及远程目录,然后执行脚本自动化上传:
1 | python ftpblog.py |
使用的是 https://freessl.cn/ 生成免费的 HTTPS 证书。
打开网站,输入你的域名以及邮箱,根据提示下载一个工具 KeyManager,然后生成证书:
然后回到 freessl.cn 网站页面进行 DNS 验证:
目的就是为了验证你的域名的所有权。这里根据提示,去 DNS 解析的地方设置解析记录。
验证成功之后使用 KeyManager 导出证书:
然后会得到一个类似于 rebootcat-com-nginx-0909002710.zip
的包,解压之后会得到两个文件:
1 | rebootcat.com_chain.crt |
用编辑器打开这两个文件,或者直接 cat
这两个文件,一个是 SSl 的证书,一个是 SSL 密钥,把这两个文件的内容拷贝到虚拟主机面板的 SSL设置处:
并且开启了 http 跳转 https。
到此, HTTPS 证书设置就完成了。
注意需要记住 KeyManager 的主密码
试试用 https://rebootcat.com 看能否正确访问呢?
由于购买的是香港的虚拟主机,毕竟一年也才几十块钱,很难说服务提供商就跑路了,为了避免这一类事情发生的时候导致博客无法访问,有必要对博客网站进行一些云监控,一旦出现异常,则告警。
免费的网站监控工具有很多,我用的是阿里云的监控以及 UpTimeRobot 的网站监控:
这个自行设置一下,注意设置好报警阈值,不然可能会造成误报:
所以一旦出了很严重的报警,那么说明你的服务商跑路了。。。
这里就简单贴一下迁移前后的效果图:
迁移前:
迁移后:
可以看到还是有很好的改善的,毕竟服务器位于香港。
到此,博客迁移就完成了,访问速度提升了,也开启了 https。接下来我会考虑对博客首页做一些优化,但由于现在图片走的其实还是 jsdelivr 的国外 cdn,所以速度还是有点慢,可以考虑直接把图片放到网站根目录下,毕竟现在使用的是虚拟主机。
后面再说吧,也可以考虑把图片等放到阿里云或者腾讯云对象存储上。
Blog:
2020-09-20 于杭州
By 史矛革
一直没有时间来整理下博客搭建的一些事情,现在补上一篇,给 Hexo Next 博客添加一个相册功能,使用瀑布流的方式。
此种方式的优点是免费,不需要购买其他的对象存储产品;并且使用的是 github 作为图床,图片不会丢失。
早期的博文使用的是七牛云的免费存储,结果后来被他们删掉了。。。结果造成文中的一些图片链接都是 404,有兴趣的可以翻一翻我早期的博客。
由于采用的是 github 仓库存储图片,但是 github 对单仓库有 50MB 的大小限制,所以单仓库可能不能够存储太多的文件;
解决方法就是建立很多的图片仓库(稍微有点费劲,不过是行得通的);另外上传的单张图片大小最好不要太大。
还有个缺点就是得折腾啊,且看我后文。
各位可以参考下我的相册瀑布流: 摄影
开始之前,需要简单介绍一下,我参考的是 Hexo NexT 博客增加瀑布流相册页面 这篇文章,文中涉及到的脚本主要都是 js 实现;与他不同的是,由于我对 js 的掌握远远不及我对 Python 的掌握,故部分脚本我采用了 Python 实现。
所以在开始操作之前,你可以根据自己的技能,选择不同的方式。如果你擅长 python,那么跟着我来吧。
去到博客根目录:
1 | mkdir -p source/photos |
然后进入 photos 目录:
1 | cd source/photos |
把下面的粘贴保存:
1 | --- |
添加了 photos 页面后,需要在 next 配置文件中修改:
1 | vim themes/next/_config.yml |
找到 menu 项,填入如下:
1 | photos: /photos || fas fa-camera-retro |
比如我的是这样的:
1 | menu: |
完成之后还需要修改一下这个文件:
1 | vim themes/next/languages/zh-CN.yml |
找到 menu 项,加入如下一行:
1 | photos: 摄影 |
比如我的是这样的:
1 | menu: |
OK,到这里应该能看到这个 摄影 页面了,你可以现在本地测试一下看:
1 | hexo s -g |
首先需要在 source 目录下新建一个 js 目录,用来保存自定义的一些 js 脚本;
1 | mkdir -p source/js |
然后新建 mygrid.js 文件,粘贴下面的一段代码:
1 | // 获取网页不含域名的路径 |
或者你可以直接在我的博客上找到: rebootcat.com/mygrid.js
1 | wget https://rebootcat.com/js/mygrid.js -O source/js/mygrid.js |
我们再次回到 photos 目录,创建文件 photoslist.json:
1 | vim source/photos/photoslist.json |
然后输入如下的内容:
1 | [ |
OK, 到现在应该你能从博客上看到这两张图片了:
1 | hexo s -g |
本地测试一下,如果你能看到在博客的 摄影 页面看到这两张图片,那么说明你的配置没问题,你可以进行接下来的操作了;如果你不能正确显示,说明前面的步骤出了问题,自己研究调试一下;如果你还不能解决,欢迎联系我。
上面可以看到,photoslist.json 存放的是图片的信息,mygrid.js 解析 photoslist.json 这个文件,然后在 photos 页面添加 dom.
所以核心的部分在于 photoslist.json 文件,我们可以分析下这个文件:
1 | 1080.1920;WechatIMG114.jpeg;https://cdn.jsdelivr.net/gh/smaugx/MyblogImgHosting/rebootcat/photowall/cat/WechatIMG114.jpeg;https://cdn.jsdelivr.net/gh/smaugx/MyblogImgHosting/rebootcat/photowall/cat/WechatIMG114_small.jpeg |
photoslist.json 保存的是一个 list,list 中每一行是一张图片的信息,包括原始图片大小、文件名、原始图片cdn链接、缩略图cdn链接。
前面已经提到,我们的图片是使用了 github 作为图床(仓库),然后使用 jsdelivr 进行 cdn 加速。所以我们应该准备好图片文件,然后上传到仓库。
在 https://github.com 上创建图片仓库。
当仓库容量超过 50MB 之后需要重新再新建一个仓库
本地克隆仓库,然后把图片放入仓库,上传(这里以我的仓库为例)
1 | git clone git@github.com:smaugx/MyblogImgHosting_2.git blogimg_2 |
编写 python 脚本或者直接从我的网站下载:
1 | wget https://rebootcat.com/js/phototool.py -O phototool.py |
脚本如下:
1 | #!/usr/bin/env python |
这里重点需要关注的是:
1 | config = { |
简单解释一下这个脚本:
上面几个参数一定要配置对了。
那么简单解释一下脚本的功能:
脚本会递归的查找 img_path 目录下的图片,然后进行一定的压缩(99%),这里的压缩目的并非真的是压缩,而是为了去除一些敏感信息,比如 GPS 信息。注意这里会覆盖掉原始图片。然后会生成图片的缩略图,同时根据上面的几个配置参数,生成两个 cdn url,一个对应的是原始图片的 cdn url,一个是缩略图的 cdn url.
然后执行:
1 | python phototool.py |
脚本执行完,就会增量生成 photoslist.json,可以先打开检查下对不对,或者把里面的 cdn url 复制出来从浏览器看能不能访问。
注意需要把本地图片仓库推送到远程。
这个 phototool.py 脚本你可以随便放在哪里,当你更新图片之后重新执行一遍就可以了。当然你也可以像我一样,跟网站源码直接放一起,所以你可以看到,我直接放到了 js 目录。
把新图片放到本地仓库,然后执行:
1 | python phototool.py |
检查一下 photoslist.json 文件对不对,然后发布博客:
1 | hexo d -g |
发布之后,记得把本地图片仓库推送到远端,不然 jsdelivr 无法访问到。
至此,一个相册瀑布流就制作完成了!
由于我是采用回忆的方式来写的博文,所以文中可能会有一些小的修改或者配置我忽略了,不过问题不大,大家如果碰到问题了可以自行研究一下,能解决的。
采用 github 作为图床来存放大量的瀑布流图片墙,方案是没问题的,只不过可能由于仓库容量的限制,需要在 github 上构建多个图片仓库。
对于我来说,github 图片仓库主要用来存放博文中涉及到的图片。至于图片墙,我再另想办法吧。
Blog:
2020-09-19 于杭州
By 史矛革
2020 年,新冠肆虐。
最近对于区块链的想法有点消极。简单谈一下。
纵观整个区块链行业,公链项目死了很多,还活着的也在拖着,除了少数明星公链。也许未来的几年之内,这种状况还会持续下去,对于公链项目来说,要么死,要么拖下去再死,要么成为明星。无论哪一条路,都异常难!
区块链目前能解决的问题范围依然还是比较局限,一方面受限于技术层面,一方面受限于政治层面。技术方面的难点,在于架构,在于算法设计,在于安全,在于通信。
架构上目前行业普遍追求可扩展的架构,这样的目的在于提高 TPS,为未来可能存在的真实大量业务提供服务,当然必然为此牺牲去中心化属性,牺牲安全属性,抉择就在于追求什么目标?从比特币到以太坊,再到 EOS,再到各种分片的公链,可以理解为逐步为了高 TPS 改良,因为比特币的交易速度实在是太慢了,为此我们势必要有一条满足我们交易需求的公链,至于这个交易频率需要多快,也许可以对标中心化得出答案。
比如 visa 的 TPS 在 1000 ~ 2000,银联在 2000 左右, paypal 600 ~ 1000等等。这里就不得不提出一个疑问,对于公链来说,追求高 TPS 是不是一个伪需求?
算法层面,为了保证零信任网络内部达成一致共识,会涉及到大量的收发包以及加解密过程,然而至今没有一条公链的共识算法能称得上权威或者标杆,整个行业仍然处于研究阶段,不同的公链之间互相参考学习,然后进行创新以及试验。
这个过程可能很漫长,需要大量的人才投入贡献。这也就意味着现阶段的公链,至少在共识算法层面,不能达到一个质的飞越。也许在未来,还得依靠其他的手段做改良。
安全层面,相较于中心化的系统,基本可以理解为系统是直接暴露在外网上,但是目前来说,中心化的系统里也越来越多出现云系统,一定程度也直接暴露在外网上。除了这点,更为严重的是,公链代码是需要开源的,也就意味着可以随意修改代码进入网络,这对安全提出了很大的要求。另外,公链上直接跑的就是金钱,也就会成为各路黑客的重点攻击对象。从比特币、以太坊,到不知名的公链,均发生过不下一起的安全攻击事件,有的直接分叉,有的直接倒闭,有的币价腰斩,有的让用户、交易所损失惨重。
通信上,通信层面受限于真实的、不确定的、波动的网络环境,并且由于算法的设计,会带来大量的广播,一条消息进入网络就会变成 n 倍的消息量。也许随着 5G 技术的发展,在通信层面能够彻底解决这些问题,但目前来说,5G 的大规模落地还有很大的不确定性。
政治层面的局限就太多了,毕竟去中心化金融系统挑战的是集权的中央政府,比特币发展 10 年,好像依然只有少数国家是承认比特币的合法性的,比特币期间还被多次宣布死亡。
对于区块链来说,真可谓路漫漫其修远兮,未来依然充满了巨大的挑战以及极大的不确定性,不过我们依然也可以说变数中才存在乘风破浪的机会,就看从哪个角度看了。
另外,区块链的技术圈和炒币圈似乎是割裂开来的,我本身是比较反对这种方式的,炒币的热度远远超过技术本身的热度,毕竟人都是逐利的,由此,一方面会给区块链的技术发展带来一些积极正面的影响和热度,让更多的人认识到区块链,加入到这个区块链的发展中来;但另外一方面,炒币的人似乎也压根不关心技术的东西,他们需要的只是几倍几倍的涨幅,也许项目方随便吹吹牛皮,拉几位 “大佬” 站台,他们就能往里投钱,而压根不关心项目的技术,由此造成了各式各样的骗局,反过来让真的想了解区块链技术,对区块链技术感兴趣的人对此失去兴趣,形成恶性循环。而现在,整个币圈基本是这样!
这不得不让人反思,区块链到底解决了什么?是否只是沦为资本的工具?还是一直在鼓吹伪命题伪需求?
前几年,听到最多个一个词就是 “信仰”,对区块链的信仰,对比特币的信仰。但是对于一个技术来说,信仰这个词是不是稍微有点沉重和多余? 2000年 的互联网技术,可惜无缘参与,不知道当时是否有很多人张口闭口一个 “信仰”,虽然无从得知,但我可以大胆猜测一下,应该是没有这样的现象的。
很多人把区块链技术和互联网技术做类比,比较两者的发展过程,由此得出一些结论,比如目前区块链行业的状态就是 2000 年的互联网,充满了寒冬、泡沫,但是再看看今天的互联网怎么样呢?由此说明区块链会成为下一个互联网。
我不否认一定程度上是对的,我们无法预估未来的发展,但是至少我们要对未来充满期待,对未来充满信念,而非 “信仰”。也许这个词多半是币圈流传,用来忽悠新人接盘的。但是把区块链和币圈割裂开来的方式似乎也是不妥,如果没有炒币这波人,也许区块链没有这么高的热度,但是就是这波炒币的人,让区块链行业乌烟瘴气,骗局和圈钱时有发生。所以,”信仰”这个词,真的没必要用在区块链上。区块链也许会改变世界,也许不会,它就是一个技术。
再回到当前,区块链行业追求的新名词也早已从公链、TPS、分片变成了 filecoin/ipfs、defi,真是瞬息万变。但是最近,也陆陆续续听说很多 defi 维权的新闻,至于 ipfs,沉寂了五六年时间,今年加上激励层filecoin 瞬间成为整个币圈的人都在谈论的明星项目,但是为什么这么火热呢?因为大家都在说这是第二个比特币,错过了比特币的挖矿,不要错过 ipfs 的挖矿。然后各种矿机卖的火热,参与的矿工也越来越多,官方还搞了竞赛,进一步助推了这波热度。
另外,观察一个现象,ipfs 是国外的项目,但在中国异常火热,这背后是否有一些幕后操手呢?再说 filecoin 主网,竞赛过程中 bug 不断,很明显内部没有经过比较系统的测试就赶鸭子上架的方式宣布即将主网上线,并开启挖矿竞赛。。。被资本裹挟的迹象很重,当然对于项目方来首,错过了这个热度,也许就永远没有机会了。于是不得不被资本裹挟。
这纯属我个人的阴谋论,不要在意。我想表达的是,区块链越来越沦为资本的工具,区块链从业者如何探索出一条正道迫在眉睫!
说了很多题外话,简单总结一下就是:
路漫漫其修远兮,区块链也许能改变世界,也许不能!
2020-09-16 于杭州
By 史矛革
]]>我是一个 linux c++ 开发者,但是一直对 Makefile 的语法很是头痛,每次都记不住,所以每次写 Makefile 都很痛苦,Makefile 里需要你自己编写依赖和推导规则,这个过程能不能简单点呢?
对于编译一个 C++ 工程来说,也许需要的就是头文件路径、库路径、编译参数,剩下的东西基本也不重要,这三样足够去编译一个工程了。所以有没有一个工具能简单点的去实现 C++ 项目的构建呢?
答案是有的,上一篇博文 scons构建C++项目 介绍了 使用 scons 来构建 C++ 项目,大大提高了编写构建脚本的效率,使用起来也极为方便,对于熟悉 python 的童鞋来说真的是大大的福音;但 scons 的问题就是在大型项目的时候构建起来可能会很慢(听说的)。那么有没有其他的工具呢?
当然有,cmake 就是这样的一个工具,既能满足跨平台的编译,并且屏蔽了 Makefile 蛋疼的语法,使用一种更加简单的语法编写构建脚本,用在大型项目也毫无压力。
当然,对于我个人来说,cmake 的使用还是有门槛的,刚接触 cmake 可能还是会被它的语法搞的头疼(cmake 的语法也还是挺折腾的)。但是别急,沉下心来,本篇博文就带你从 cmake 入门到编写一个复杂工程的实战。
这里直接引用官网的解释:
CMake is an open-source, cross-platform family of tools designed to build, test and package software. CMake is used to control the software compilation process using simple platform and compiler independent configuration files, and generate native makefiles and workspaces that can be used in the compiler environment of your choice. The suite of CMake tools were created by Kitware in response to the need for a powerful, cross-platform build environment for open-source projects such as ITK and VTK.
CMake 是一个开源的跨平台的构建工具,语法简单,编译独立,并且很多知名大型项目也在用 CMake,比如 KDE、Netflix 、ReactOS等。
OK,话不多说,如何使用呢?
1 | sudo yum install cmake3.x86_64 |
现在最新版的 cmake 已经到 3.18.2 了。我使用的是 3.17.2 版本。
1 | $ cmake --version |
注:本文以一个多源文件,多目录结构的项目 mux 为例,介绍 cmake 的使用,相关源文件以及cmake 脚本可以直接查看源项目。
使用 cmake 来构建 C++ 项目,需要先编写 cmake 构建脚本,文件名为 CMakeLists.txt,项目顶层目录需要放一个 CMakeLists.txt,同时子目录可以根据需要放置 CMakeLists.txt。
那么先来看看 CMakeLists.txt 长啥样?
1 | cmake_minimum_required(VERSION 3.8.0) |
完整的 CMakeLists.txt 见 我的github,同时我也会以我的github项目 mux 为例,介绍 cmake 的使用。
上面的 CMakeLists.txt 乍一看,好多内容,但是别慌,我们来一个个说。
注意:cmake 的语法可以分为命令(函数)和参数。 命令不缺分大小写,参数区分大小写。
注意:cmake 的语法可以分为命令(函数)和参数。 命令不缺分大小写,参数区分大小写。
1 | cmake_minimum_required(VERSION 3.8.0) |
1 | set(CMAKE_CXX_STANDARD 11) |
1 | project(MUX CXX C) |
设置完项目名称之后,会自动创建两个变量 <PROJECT-NAME>_SOURCE_DIR
和 <PROJECT-NAME>_BINARY_DIR
,对于 mux 这个项目来说,也就是 MUX_SOURCE_DIR
和 MUX_BINARY_DIR
。
MUX_SOURCE_DIR
表示工程顶层目录; MUX_BINARY_DIR
表示 cmake 构建发生的目录。
因为你一定熟悉或者用过下面的命令或步骤:
1 | mkdir cbuild |
通常我们会单独新建一个 cbuild 目录,用来构建项目,并且存放过程中产生的文件。那么 cbuild 目录就是 MUX_BINARY_DIR
表示的目录,cbuild 的上一级目录也就是项目顶层目录就是 MUX_SOURCE_DIR
表示的目录。
如果你没有单独新建
cbuild
目录,而是直接在项目顶层目录使用cmake .
,那么上面两个变量均指项目顶层目录。
详见 https://cmake.org/cmake/help/latest/command/project.html
1 | add_definitions( |
上面是我随便写的两个宏 TEST1
和 TEST2
,那么在c++代码中通常是这样的:
1 |
|
当然要开启这个宏也可以不用写在 CMakeLists.txt 文件中,可以直接这样使用:
1 | mkdir cbuild && cd cbuild |
这个根据你的项目需求来操作。
1 | option(XENABLE_TEST3 "enable test3 marco" OFF) |
使用 option 命令可以自定义一些变量的值,作为一些条件判断的开关很方便。
详见 https://cmake.org/cmake/help/latest/command/option.html
1 | # common compiling options |
这里就是一些编译选项,根据自己的项目需求修改。
1 | set(EXECUTABLE_OUTPUT_PATH ${MUX_BINARY_DIR}/bin) |
可以看到上面用到了 MUX_BINARY_DIR
这个变量,也就是说最终编译出来的二进制程序和lib 库会存放在 cbuild/bin
和 cbuild/lib
中。
1 | message(STATUS "CMAKE_BUILD_TYPE:" ${CMAKE_BUILD_TYPE}) |
打印一些调试信息,或者编译信息到终端,使用的是 message 命令。
详见 https://cmake.org/cmake/help/latest/command/message.html。
1 | # include header dirs |
分别解释一下:
CMAKE_SOURCE_DIR
表示工程顶层目录,也就是 MUX_SOURCE_DIR
;
CMAKE_CURRENT_BINARY_DIR
表示当前处理的 CMakeLists.txt 所在的目录,对于子目录中的 CMakeLists.txt 来说,即表示这个子目录。
通常这两个是常用的,必须的。然后使用 include_directories
命令包含其他的一些头文件路径。
1 | # link lib dirs |
LIBRARY_OUTPUT_PATH
就是上面设置的编译目标二进制库的存放路径,因为实际项目中,子模块之间可能会有一些依赖,子模块单独编译成一个库,然后让其他模块链接。这个目录也就是 cbuild/lib
目录。
1 | add_subdirectory(demo/bench) |
使用 add_subdirectory
命令把子模块包含进来,必须确保每个子目录下面有一个 CMakeLists.txt 文件,不然会报错。
以上就是工程顶层目录的 CMakeLists.txt 的内容,分析下来是不是很清楚呢?
那么工程顶层目录的 CMakeLists.txt 其实做的事情就是设置一些基本的变量,宏开关,编译参数,头文件路径,依赖库路径,编译目标保存路径等等,子目录中的 CMakeLists.txt 才是真正产生编译目标的(exe和lib)。
1 | # keep all cpp files in varibale ${epoll_src} |
源文件在这:戳我
使用 aux_source_directory
添加源文件,相当于把 src 目录下的所有 c++ 文件保存到 epoll_src
这个变量中;
使用 add_library
生成目标库(根据需要可以生成静态库和动态库,分别使用 STATIC 和 SHARED)
然后就是添加这个模块需要依赖到的其他模块,以及链接参数。
上面的代码最终就会在 cbuild/lib
目录下生成一个 libepoll.a
文件。
1 | # build target echo_server |
源文件在这:戳我
和生成库大体是类似的,区别是使用的是 add_executable
这个命令。
其他子模块的 CMakeLists.txt 见我的github.
上面详细的介绍了 CMakeLists.txt 的写法,如果仿照本文,应该也能写出适合你项目的构建脚本,但是可能还不够,其他语法自行 google 学习。
上面其实是以我的项目 进行的演示,有必要解读一下这个项目的结构层次:
1 | $ tree mux -d |
mux 是工程顶层目录,下面包含的 epoll
、mbase
、message_handle
、transport
这几个目录,均各自打包成一个静态库; demo
目录下分别包含 bench
和 echo
两个目录,这两个目录下需要构建可执行程序。
所以首先是epoll
、mbase
、message_handle
、transport
这几个目录生成静态库,也就是最终会在 cbuild/lib
目录生成 libepoll.a
, libmbase.a
, libmsghandler.a
, libtransport.a
, 然后 bench
和 echo
下的代码依赖于前面的几个模块,生成可执行程序。
前面其实已经提到了,基本的构建命令如下:
1 | mkdir cbuild |
其中注意,如果你没有单独构建 cbuild 目录的话,可能会生成一些中间临时文件污染了目录。并且注意,cmake 后面的 ..
表示的是工程顶层的 CMakeLists.txt 的目录。所以如果直接使用的是工程顶层目录构建的话,就应该是 cmake .
1 | $ cmake .. |
看看生成了啥:
1 | $ ls cbuild/bin/ |
Over!
cmake 的构建其实认真熟悉之后,也还是能快速上手的,不要产生排斥心理,不然学起来就很慢很费劲。所以建议第一次接触 cmake 的或者以前一直抵触 cmake 的童鞋,静下心来,认认真真的看完本文或者其他的入门例子,那么你也能快速写一个多目录,多层次结构的 cmake 工程。
cmake 中其他的一些用法,建议随时查看官方的 cook book.
加油,少年,别怕!
另外,文中涉及到的项目可以在我的github 找到。
Blog:
2020-09-02 于杭州
By 史矛革
我是一个 linux c++ 开发者,但是一直对 Makefile 的语法很是头痛,每次都记不住,所以每次写 Makefile 都很痛苦,Makefile 里需要你自己编写依赖和推导规则,这个过程能不能简单点呢?
对于编译一个 C++ 工程来说,也许需要的就是头文件路径、库路径、编译参数,剩下的东西基本也不重要,这三样足够去编译一个工程了。所以有没有一个工具能简单点的去实现 C++ 项目的构建呢?
答案是有的,Scons 就是答案。
这里直接引用官网的解释:
What is SCons?
SCons is an Open Source software construction tool—that is, a next-generation build tool. Think of SCons as an improved, cross-platform substitute for the classic Make utility with integrated functionality similar to autoconf/automake and compiler caches such as ccache. In short, SCons is an easier, more reliable and faster way to build software.
What makes SCons better?
最大特点就是使用 Python 语法来编写编译构建脚本,并且支持依赖自动推导,支持编译 C/C++/D/Java/Fortran等项目,并且是跨平台的(因为 python 是跨平台的)。
所以如果你对 python 熟悉的话,而且你和我对 C++ Makefile 有一样的烦恼,那么这对你将是一个好消息。 你将可以用 python 来编写构建脚本,而且会很简单,对于复杂的大型项目也能快速构建好。(也许只要 30 分钟)
因为 scons 是基于 python 来构建的,所以毋容置疑,首先是需要准备好 python 环境,然后使用下述命令安装 scons 工具。
1 | pip install scons |
注:本文以一个多源文件,多目录结构的项目 mux 为例,介绍 cmake 的使用,相关源文件以及cmake 脚本可以直接查看源项目。
scons 构建脚本由一个 SConstruct 文件和多个 SConscript 文件构成。
SConstruct 通常位于项目顶层目录,然后 SConscript 通常位于子目录(子模块)。
那么来看一下 SConstruct 脚本长啥样?
1 | #!/usr/bin/env python |
来分析一下这个文件,源文件可以直接在 我的github下载。
SConstruct 文件主要做了两件事:
需要注意的是 SConstruct 和 SConscript 共享变量使用的就是 env 这个变量,你可以看到上面有一句:
1 | Export('env') |
这句很重要。
那么位于子模块或者子目录的 SConscript 文件长啥样呢?
1 | #!/usr/bin/env python |
来分析一下这个文件,源文件可以直接在 我的github下载。
SConscript 主要做了两件事:
当然,还有一点很重要,上面其实提到了,SConscript 和 SConstruct 用来共享变量使用的是 env 这个变量,所以你可以看到一句很重要的:
1 | Import('env') |
构造源文件列表,对于 Python 来说,简直是小菜一碟,太简单了;
然后如何生成目标文件呢?
1 生成二进制文件
1 | env.Program(target = os.path.join(bin_dir, echo_server_bin), |
2 生成静态库
1 | env.StaticLibrary(target = os.path.join(lib_dir, epoll_lib), |
3 生成动态库
1 | env.SharedLibrary(target = os.path.join(lib_dir, epoll_lib), |
上面 3 个函数的参数都是类似的:
attention:
上面有一个坑我自己碰到的,当我构建目标生成一个静态库的时候,需要链接其他的静态库,如果使用 $LIBPATH 和 $LIBS 指定链接库的话,scons 并没有链接这些库。尝试了很多方法,搜索了很多,也没有解决这个问题。
最后是这样解决的。把需要链接的静态库添加到 source 参数中,和其他 cc/cpp 源文件一样放在一起,并且这些库需要使用绝对路径。
通常为了跨平台的方便,需要考虑lib 的前后缀,可以这样写:
1 | link_libraries = ['test1', 'test2'] |
上面详细讲解了如何使用 python 编写构建脚本,那么写好之后怎么用呢?
常用的几个命令:
编译:
1 | scons |
如果需要并行编译:
1 | scons -j4 |
清理:
1 | scons -c |
然后就会按照你脚本里写的方式去构建目标了。
这里贴一下 我的项目 编译的输出:
1 | $ scons |
1 | $ scons -c |
scons 使用 python 脚本来构建项目,如果对 python 熟悉的话,那么编写编译构建脚本将会大大提高效率,再也不用局限在 Makefile 的蛋疼语法里面了。
当然 scons 的缺点也有,据说在大型项目的时候,可能会很慢。这个我还没碰到过,因为没有用到大型项目中。
下一篇,分享下 cmake 构建 C++ 项目的一些语法和步骤。
另外,文中涉及到的项目可以在我的github 找到。
Blog:
2020-08-30 于杭州
By 史矛革
自动创建阿里云抢占式实例。
阿里云抢占式实例应该属于阿里云的一种闲置资源利用,性价比非常高,每小时的价格在 0.01 ~ 0.05 每小时,具体根据不同的配置和地域有差别,流量价格小于 1元/G.
抢占式实例最高可以以一折的价格购买 ECS 实例,并能稳定持有该实例至少一个小时。一个小时后,当市场价格高于您的出价或资源供需关系变化时,抢占式实例会被自动释放,请做好数据备份工作。
非常适合爬虫
非常适合爬虫
非常适合爬虫
也适合程序员个人日常开发使用,上班来创建,下班释放,开销基本可以控制在在 1毛 ~ 2 毛。
对于我来说,最近在写一个爬虫,看了很多代理都很贵,免费的又不稳定,正好了解到阿里云的抢占式实例,所以非常满足我的需求。
但是要注意,这个实例是有可能被释放的,但是不用担心,比如香港地区的释放率最近(2020-08-19)小于 3%. 另外,每个人可以最大创建 100 个实例,所以还是不用太担心。
脚本仓库: https://github.com/smaugx/aliyun_spot
支持以下一些参数:
可以使用脚本提前释放一个或者多个实例。
创建的时候可以设置自动释放时间,当然也支持随时手动释放。
1 | $ python run_aliyunspot.py |
1 | git clone https://github.com/smaugx/aliyun_spot.git |
1 | $ cp test_config.py config.py |
当然你也可以不用修改其他配置,只需要把你的 access_id 和 access_secret 填进去就可以,以及 key_pair_name 填进去。(见后文章节 #阿里云官网操作# )
默认创建的是香港地区的抢占式实例,内存 500MB, 1 CPU, 系统盘 20GB, 按流量计费(1元/G), 公网出口带宽 10Mbps, 1 小时候自动释放。
2020-08-19 上述默认配置的实例价格在 ¥ 0.018 /时。
如果你觉得这个配置(cpu/mem)无法满足你的要求,那么可以调整 instance_type 这个参数,表示实例规格,详细可以查看阿里云官网页面 云服务器 ECS > 实例 > 实例规格族
1 | $ python run_aliyunspot.py -c |
如上,创建成功。然后接下来就可以使用 ssh 登录:
1 | $ ssh -i ~/.ssh/~/.ssh/aliyunspot.pem root@8.210.245.226 |
1 | $ python run_aliyunspot.py -l |
注意,上面仅仅是把之前创建并保存的实例信息从文件当中读取出来,并没有与 aliyun 交互。
1 | $ python run_aliyunspot.py -r -s i-j6caz353cisgl3fzenwi i-j6cbyis12fb1fpzk59fv |
上面提到了几个配置是需要在阿里云官网操作的。
阿里云官网的使用还是挺复杂的,因为功能太多了,花费了我至少一个上午的时间才熟悉了整个操作,完成了整个脚本
所以整理了这个脚本方便大家使用,对阿里云的操作只需要下面几个:
chmod 600 aliyunspot.pem
OK, 到这里基本上得到了我们脚本里需要的几个配置:
把上述几个配置填到 config.py 中即可。
Blog:
2020-08-24 于杭州
By 史矛革
valgrind 是什么,这里直接引用其他人的博客:
Valgrind是一套Linux下,开放源代码(GPL
V2)的仿真调试工具的集合。Valgrind由内核(core)以及基于内核的其他调试工具组成。
内核类似于一个框架(framework),它模拟了一个CPU环境,并提供服务给其他工具;而其他工具则类似于插件 (plug-in),利用内核提供的服务完成各种特定的内存调试任务。
Valgrind的体系结构如下图所示:
关于 massif 命令行选项,可以直接查看 valgrind 的 help 信息:
1 |
|
对其中几个常用的选项做一个说明:
经过上面的了解,接下来可以开始内存数据采集了,假设我们需要采集的二进制程序名为 xprogram:
1 | valgrind -v --tool=massif --time-unit=B --detailed-freq=1 --massif-out-file=./massif.out ./xprogram someargs |
运行一段时间后,采集到足够多的内存数据之后,我们需要停止程序,让它生成采集的数据文件,使用 kill 命令让 valgrind 程序退出。
attention: 这里禁止使用 kill -9 模式去杀进程,不然不会产生采样文件
ms_print 是用来分析 massif 采样得到的内存数据文件的,使用命令为:
1 | ms_print ./massif.out |
或者把输出保存到文件:
1 | ms_print ./massif.out > massif.result |
打开 massif.result 看看长啥样:
1 | -------------------------------------------------------------------------------- |
这张图大概意思就表示堆内存的分配量随着采样时间的变化。从上图可以看到堆内存一直在增长,可能存在一些内存泄露等问题。
往下看还能看到内存的分配栈:
1 | 0 0 0 0 0 0 |
能看到内存分配的调用堆栈情况,据此可以看到哪里分配的内存较多。
ms_print 一定程度上不够直观,所以祭出另外一个分析内存采样数据的大杀器 – massif-visualizer,它能可视化的展示内存分配随着采样时间的变化情况,并能直观的看到内存分配的排行榜。
注意: massif-visualizer 目前好像只支持 linux 环境,并且具有桌面环境的 Linux. (mac/windows 的版本我没有找到)。
故我们采用 ubuntu-20.04-lts 作为分析环境。
直接在软件中心搜索 massif-visualizer,然后安装
双击 massif-visualizer 启动软件之后,打开并选中某个 massif.out 文件,或者用命令行的方式打开:
1 | massif-visualizer ./massif.out |
启动后,能直观的看到内存随采样时间的变化情况:
调整上面的选项 Stacked diagrams 值后:
鼠标悬停之后也能看到每条曲线某个 snapshot 对应的内存分配情况。
界面右边是内存调用的堆栈:
点击界面下面的 Allocators 按钮之后,可以看到内存分配的排行榜:
是不是很方便?
其实用于分析内存分配情况的利器还可以采用 google-perftools,也是采用对内存采样的方式进行采集,然后生成不同的内存采样文件,结束之后比较两个内存采样文件,就可以分析内存分配情况,同时也能展示初内存分配的函数调用栈。不过相比较于 valgrind 的 massif 插件,google-perftools 是需要代码侵入的,并且不能直观的展示内存随采样时间的变化情况。
而 massif 采样的内存数据文件,借助 massif-visualizer 工具就能直观的感受到内存分配随采样时间的变化情况。
Blog:
2020-06-16 于杭州
By 史矛革