

CPU 缓存分为数据缓存与指令缓存。
伪共享:假设cache line是64字节,我们在一个64字节的并且和cache line 对齐后的内存中放入两个4字节的整数A和B,然后线程a和b分别访问A和B,在内存层面的语义是这两个线程分别独享一块内存区域,操作时互不干扰,但是在缓存cache line层面他们是共享一个cache line的,是一个"原子的数",这就是伪共享。
缓存层面的伪共享的一致性由硬件保证,对程序员透明,也就是对这个"原子数"的操作不用显式加锁,但是伪共享会降低程序效率。
进程申请内存的速度会受到内存池的影响,隐藏的内存池如下:

其中不同的 C 库内存池,都有它们最适合的应用场景,例如 :TCMalloc 对多线程下的小内存分配特别友好,每个线程独立分配内存,无须加锁,所以速度更快;而Ptmalloc2 则对各类尺寸的内存申请都有稳定的表现,更加通用。
在栈中分配内存比内存池中堆分配内存要快很多。
作为索引,哈希表是第一选择。由于哈希表基于数组实现,数组可根据下标随机访问任意元素,即下标乘以元素大小,再加上数组的首地址,就可以获得目标访问地址,直接获取数据。因此查找时间复杂度为O(1),不会随着业务规模增长而变化。
位图,哈希表的变种,限制每个哈希桶只有 1 个比特位,消耗的空间少。常用于解决缓存穿透的问题,以及快速判断对象是否存在。(布隆过滤器也可判断状态)
由于哈希表不支持范围查询与遍历,如果业务需要,可以考虑B树。
那么如何降低 / 解决哈希冲突呢?
1、由于生产环境需要考虑容灾,如将哈希表原地序列化为文件,保证新进程快速恢复哈希表。相较于拉链法,开放寻址法更擅长序列化数据。
2、注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突。
3、优化哈希函数。(集群的负载均衡正是用到了这个思想)
磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。
为什么读取磁盘文件时,一定要做上下文切换呢?
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端,然而读取磁盘或者操作网卡都由操作系统内核完成,内核权限最高。对于执行的read和write系统调用,一定会发生2次上下文切换。(用户态到内核态间切换)下图为零拷贝示意图:

零拷贝技术:它是操作系统提供的新函数,同时接收文件描述符和 TCP socket 作为输入参数,这样执行时就可以完全在内核态完成内存拷贝,既减少了内存拷贝次数,也降低了上下文切换次数。
零拷贝技术基于 PageCache(磁盘高速缓存)。
1、PageCache 缓存了最近访问过的数据,提升了访问缓存数据的性能。
2、PageCache协助 IO 调度算法实现了 IO 合并与预读(这也是顺序读比随机读性能好的原因),进一步提升了零拷贝的性能。
异步 IO + 直接 IO:高并发场景处理大文件时,应当使用异步 IO+直接 IO 来替换零拷贝技术。(可以设定一个文件大小阈值)
————————————————————————————————————
知识点补充:
常见网络IO模型:
其中前三种为同步模型,AIO为异步;BIO是最简单、最常见的IO模型(Linux下默认所有socket为blocking的)。
零拷贝根据应用场景有不同实现方式:
1、sendfile:直接从磁盘读到内核发送到网卡,不需经过用户态到内核态的拷贝转换。
2、mmap:虚拟内存映射到用户空间,需要保证内存与磁盘的数据一致性。
————————————————————————————————————
1、使用多线程,为每一个请求分配一个线程来执行。弊端:单个线程消耗内存过多,没有足够的内存创建几万线程实现并发;线程切换导致上下文切换,耗费CPU资源。
2、异步编程,实现用户态的请求切换。异步化依赖 IO 多路复用机制的同时,还需要把阻塞方法改为非阻塞方法(IO多路复用+回调函数)。处理基于 TCP 的应用层协议时,一个请求的处理代码必须被拆分到多个回调函数中,由异步框架在相应的事件生成时调用它们。弊端:代码书写难度大,易出错。
3、使用协程,协程可看作用户态的线程。与异步框架不同点在于,协程把异步化中的两段函数封装成一个阻塞的协程函数。在该函数执行时,由协程框架完成协程之间的切换,协程是无感知的。弊端:由于一个线程可以包含多个协程,如果协程触发了线程的切换就会导致该线程上的所有协程都阻塞,所以需要使用生态完善的协程,如GO语言天然支持协程。python 3内置库Asyncio支持原生协程,但生态不够完善,基于aiohttp可实现一些小的服务。
常见的各种锁是有层级的,最底层的两种锁就是互斥锁和自旋锁,其他锁都是基于它们实现的。
互斥锁:当 A 线程取到锁后,互斥锁将被 A 线程独自占有,当 A 没有释放这把锁时,其他线程的取锁代码都会被阻塞。(阻塞是由操作系统内核提供的信号量实现的)

互斥锁的线程切换由内核完成,简化了业务代码使用锁的难度。但是为每个请求单独开一个线程是不现实的,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。当取不到锁时,互斥锁用“线程切换”来面对,自旋锁则用“忙等待”来面对。
自旋锁:自旋锁比互斥锁快得多,它通过 CPU 提供的 CAS 函数在用户态代码中完成加锁与解锁操作。自旋锁开销少,适合资源占用时间短的情况。
# 生产级的自旋锁实现
while (true) {
//因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高
if (lock == 0 && CAS(lock, 0, pid) == 1) return;
if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义
for (n = 1; n < 2048; n <<= 1) {//pause的时间,应当越来越长
for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
}
}
sched_yield();//单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU
}读写锁:如果你能够明确区分出读和写两种场景,而且属于读多写少的情况,可以选择读写锁。它既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。
乐观锁:无论互斥锁、自旋锁还是读写锁,都属于悲观锁。即访问共享资源前,先加上锁。当并发访问共享资源,冲突概率非常低的时候,可以选择乐观锁进行无锁编程。
不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。
广播:对局域网内所有主机发送消息
组播:对部分主机发送消息
相比于TCP协议需要建立连接,UDP协议无需建立连接,所以我们常用 UDP 协议发送广播。
广播性能高的原因:1、交换机直接转发给接收方,要比从发送方到接收方的传输路径更短。2、原本需要发送方复制多份报文再逐一发送至各个接受者的工作,被交换机完成了,这既分担了发送方的负载,也充分使用了整个网络的带宽。
组播——更精准的定向广播:网络 API 中的 setsockopt 函数可以通过 IGMP 协议,向虚拟组中添加或者删除 IP 地址。当路由器支持 IGMP 协议时,组播就可以跨越多个网络实现更广泛的一对多通讯。
广播和组播能够充分地使用全网带宽,在更关注及时性、对丢包不敏感的流媒体直播中更有应用前景。
2、C10M实现之事件驱动
早些年提到的C10K,是指服务器同时处理1万个TCP连接,近来我们更希望单台服务器的并发能力可以达到 C10M,也就是同时可以处理 1 千万个 TCP 连接。
什么是事件:从网络中接收到一个报文,就可能产生一个事件,进而触发回调函数执行。由于常见的HTTP协议是基于TCP实现的,在TCP报文中有两种事件类型:读事件和写事件。
TCP 连接建立时,会在客户端产生写事件,在服务器端产生读事件。连接关闭时,则会在被动关闭端产生读事件。在连接上收发消息时,也会产生事件,其中发送消息前的写事件与内核分配的缓冲区有关。
获取事件:多路复用epoll 函数使得进程可以高效地收集到事件。
一个事件的时间不宜过长,对于处理事件代码分为以下三类:

TCP 是一个可以双向传输的全双工协议,所以需要经过三次握手才能建立连接。三次握手在一个 HTTP 请求中的平均时间占比在 10% 以上,在网络状况不佳、高并发或者遭遇 SYN 泛洪攻击等场景中,如果不能正确地调整三次握手中的参数,就会对性能有很大的影响。
客户端优化:
服务端优化:

为什么建立连接是三次握手,而关闭连接需要四次挥手呢?
close 和 shutdown 函数都可以关闭连接,但close 函数会让连接变为孤儿连接,shutdown 函数则允许在半关闭的连接上长时间传输数据。
孤儿连接:主动方close 调用后,哪怕对方(被动方)在半关闭状态下发送的数据到达主动方,进程也无法接收。用 netstat -p 命令,进程名为空。
主动方的优化:
net.ipv4.tcp_orphan_retries = 0net.ipv4.tcp_max_orphans = 16384被动方优化:
net.ipv4.tcp_wmem = 4096(动态范围下限) 16384(初始默认值) 4194304(动态范围上限)一旦发送出的数据被确认,而且没有新的数据要发送,就可以把发送缓冲区的内存释放掉
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_rmem = 4096 87380 6291456可以依据空闲系统内存的数量来调节接收窗口。如果系统的空闲内存很多,就可以把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而对方的发送速度就会通过增加飞行报文来提升。反之,内存紧张时就会缩小缓冲区,这虽然会减慢速度,但可以保证更多的并发连接正常工作。
net.ipv4.tcp_mem = 88560 118080 177120当 TCP 内存小于第 1 个值时,不需要进行自动调节; 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小; 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的。
ss 命令查看当前拥塞窗口:
# ss -nli|fgrep cwnd
cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402ip route change 命令修改初始拥塞窗口:
# ip route | while read r; do
ip route change $r initcwnd 10;
donetcp_available_congestion_control 配置查看内核支持的算法列表:
net.ipv4.tcp_available_congestion_control = cubic renotcp_congestion_control 配置选择一个具体的拥塞控制算法:
net.ipv4.tcp_congestion_control = cubic慢启动、拥塞避免、快速重传、快速恢复,共同构成了拥塞控制算法:
慢启动:拥塞窗口一开始是一个很小的值,然后每 RTT 时间翻倍 拥塞避免:当拥塞窗口达到拥塞阈值(ssthresh)时,拥塞窗口从指数增长变为线性增长。 快速重传:发送端接收到 3 个重复 ACK 时立即进行重传。 快速恢复:当收到三次重复 ACK 时,进入快速恢复阶段,此时拥塞阈值降为之前的一半,然后进入线性增长阶段。
Linux 高版本支持 BBR 算法,可以通过 tcp_congestion_control 配置更改拥塞控制算法。
拥塞控制是控制网络流量的算法,主机间会互相影响,在生产环境更改之前必须经过完善的测试。
本文参考:陶辉《系统性能调优必知必会》