并发
并发可分成并发对象和并发协调两部分。并发对象可以是进程、线程、协程、以及多机并发。并发协调包括锁(进程锁、线程锁、协程锁、分布式锁),信号,信号量,条件变量,通信队列等。
并发编程的三个特点是原子性、有序性、可见性。原子性要求写操作要么执行完要么不执行,不能中途执行一半被干扰,并发编程中需要保证所有操作都是原子的;完全要求顺序的程序无法并发,因此并发编程的特点是原子性+部分有序。可见性是针对并发读共享变量,如果并发对象之间读共享变量,就要维护共享变量的可见性,一个并发对象修改另一个并发对象可以看到。(如果只有并发写,只要考虑原子性和顺序性,不需要可见性)
并发对象
传统(或者普遍)的程序执行是串行顺序的,如果把互不依赖的逻辑由单个串行改成多个并行,就产生了并发。
常见的并发场景
- 网络访问,例如http请求、mysql连接池,创建多个链接发送接收数据,链接之间互不影响
- 文件IO,多个线程写不同文件,线程之间互不影响。如果多线程写相同文件,线程间可能相互影响,需要对并发进行优化
- 为了利用多核能力,程序创建多进程/线程,并行计算
- 生产者消费者模型(任务队列模型),多线程从任务队列拿任务处理,任务之间互不影响
协程
多线程需要处理数据更新的竞争问题,例如向数据库更新一个key,可能遇到不同线程尝试更新到不同值。即使不考虑顺序性,但由于更新key无法保证原子性,还是需要加锁维护。一个解决办法是,将key的操作绑定到线程池指定线程,也就是说,对于key的更新只交给一个固定的线程做。又比如文件系统,如果保证每个文件始终由固定的线程处理,那文件的每个io操作就天然具备了原子性。
数据密集型应用中,执行流的耗时主要来自IO和网络。如果我们让固定的线程处理某文件的io,该线程大部分时间都会在睡眠等待数据落盘和磁盘取数据。办法是在单线程之上进行多协程调度,一旦出现IO等待,自动切换协程。这样理想情况下,线程一直执行计算和内存操作。 单线程的最高QPS就是1/(一次计算和内存的耗时)。
虽然golang 实现了协程自动调度,让程序员像使用线程一样方便的使用协程(golang协程使用上和线程没有区别)。但协程的手动调度还是必要存在的。手动调度的好处是,对需要原子性的操作,保证执行完且执行期间不会协程切换。例如更新数据表期间,协程不会切走(切走其他协程就可能修改该数据->就需要加锁)
协程避免回调地狱。调用一次await,函数会自动放弃的执行权,执行权交由另外随机的协程。协程避免回调地狱前提是多个函数是可以异步调用的,如果必须等待回调函数返回才能执行下一个回调,那只能用线性回调。
单线程多协程模型是并发同时减少锁争抢的有效模型,例如作为中央服务的服务端,可以用单线程处理某个client/session独占请求和数据,从而提供了原子性,不用对client/session独占的请求/数据加锁。
协程的编程模型
- 将逻辑分为处理层、IO层、回调层。每层维护一个线程池,对于相同client请求,每层由固定的线程执行。处理层线程只处理非IO逻辑,最后把IO放到IO层;IO层协程其实是负责监听IO完成,完成后交给回调层处理回调函数。相同client请求由于每层都由固定的线程处理,不用加锁。同时单线程利用协程实现并发处理client的大量请求
- 采用async/await编程模型,利用同步的编程手法实现类似1的单线程多协程并发,但不用配置大量的回调函数
await 后面跟无线程阻塞的异步函数,当协程执行到await时,会把当前协程注册事件到协程调度器(或把当前协程作为作为切换后协程的回调函数)。当await异步函数执行完毕,会通知给协程调度器,然后转而继续执行。
理想情况下,只有协程会阻塞,线程会一直执行处理请求的工作。线程不会进入操作系统的等待队列,从而大大提高cpu的使用率。协程模型中往往一个cpu创建一个线程就足够。
1 | const fs = require('fs/promises'); |
多链接并发
一个客户端创建多个链接,并发访问服务端数据。多链接请求比单链接好。
并发协调
锁
锁,可以抽象成两点
- 锁作用是对并发场景下共享资源竞争的保护。
- 获取锁也就是某并发对象获得对某资源的操作权
凡是某资源可能被多个对象操作,就要考虑使用锁来保护
线程锁,对多个线程操作共享资源的保护;
进程锁,对多个进程操作共享资源的保护;
分布式锁,叫机器锁容易理解,对多个机器操作共享资源的保护
单机文件系统,文件锁是一种进程锁; 多机共享文件系统,文件锁是一种分布式锁
lease也可以看做一种锁,获取lease 也是获取某对象的操作权
死锁和处理
持有锁就要考虑死锁,死锁产生的原因是申请释放锁顺序不一致
- 如果对象不会发生,持有锁的情况申请锁,则不会有死锁
- 对于锁序列A, B, C, D,如果多个对象有统一的加锁顺序,例如遵循加锁A释放、加锁B释放、加锁C释放,不会有环形加锁的逻辑,则不会产生死锁。类似是是文件系统树lookup pathwalk的顺序
- 多对象加锁顺序不一致才会产生死锁
- 文件系统rename,hardlink 路径不是从根节点的pathwalk,可能会产生死锁。如rename需要对目录加写锁(原因是需要删除源目录文件,创建新目录文件,需要先对两个目录加锁,由于加锁顺序可以是A->B,B->A,会发生死锁),linux为了处理rename死锁,规定每个文件系统内每次只能进行一个 rename 操作。
- hardlink需要对源文件加元数据锁,新hardlink文件的目录加写锁,linux处理hardlink死锁问题,规定link 的对象不能是目录
- 文件系统rename,hardlink 路径不是从根节点的pathwalk,可能会产生死锁。如rename需要对目录加写锁(原因是需要删除源目录文件,创建新目录文件,需要先对两个目录加锁,由于加锁顺序可以是A->B,B->A,会发生死锁),linux为了处理rename死锁,规定每个文件系统内每次只能进行一个 rename 操作。
文件系统加锁原理
- 写文件需加文件写锁,更新文件元数据加文件元数据写锁
- 创建文件和删除文件需加目录写锁
- read, getattr, setattr需要对元数据加锁,因为需要更新元数据
- rename锁最重,需要对源和目的文件的目录加锁
lease也是一种锁,lease场景是多个机器试图操作同一服务器资源(例如多客户端试图操作共享资源
- lease由服务器管理,如果服务器运行正常,服务器可以撤销某客户端的lease锁,从而结束客户端的死锁状态
- 如果服务器崩溃,客户端可能处于死锁等待,这时候需要客户端设置超时,主动退避重试来避免死锁
- 对于文件系统,通常情况下获取lease按照顺序,rename同样可能触发死锁,分析同上‘
可以看到死锁的两种处理办法
- 避免死锁,例如保证某操作全局唯一,采用单线程、无锁编程
- 处理死锁,超时主动/被动释放锁,退避重试
顺序性
有时候还要求,从客户端无阻塞发出两个请求A->B,处理时也要按照A->B的顺序处理
常见的是在请求附加clientid和seqid,标注请求的顺序。
- 服务端对某clientid,严格按照seqid顺序处理,这样强顺序会损害性能
- 客户端维护若干slot,slot内按照seqid顺序地址,将可以并发的请求通过多个slot并发发送。服务端对每个slotid 的请求顺序处理,slot外部不保证顺序
- 更宽松的,对于请求序列A,B,C,A请求失败了可以处理B请求,但B如果执行完了,不能再执行A。
,
例如操作write(‘a’) -> read->write(‘a’)(重试) -> write(‘abc’),如果遵循3,结果是read(‘’), write(‘abc’),如果执行了重试,结果则是write(‘aabc’)。
版本序列应对一写多读场景锁
版本序列是读写锁优化的有效手段
版本是一种缓存,可以是内存的ring。在有人持有写锁期间,可以利用版本读还未写入的数据,当写锁执行完毕,则更新版本。写操作是一种事务提交,这意味着,事务未提交成功之前,状态未改变,等同于没有事务。
乐观锁应对多写场景锁
- 将原数据读到一个副本
- 对副本执行写操作,同时获得版本号/时间戳
- 原子提交,再次版本号/时间戳,如果一致,则提交;如果有问题,则重试1
原子提交也可以换成简单操作,乐观锁的写不能太重,否则可能写期间被更新。
互斥锁的实现
spinlock,
- spinlock 主要是保护多cpu操作对象的安全,可以是cpu共享的硬件变量,为了防止死锁,加spinlock需要屏蔽当前cpu的中断
mutex,存放在内存的共享变量,维护多线程的安全
中心化数据库/redis,zookeeper/etcd等中心化kv,实现分布式锁
客户端锁和服务端锁
熟悉的线程锁,进程锁是一种客户锁。在用户程序中加锁防止多线程并发修改共享变量。——如果操作系统保证每个对象只能被一个线程访问,也就是加服务端锁,就不需要客户端加锁了,当然由于性能不可能实现。
分布式锁保护共享变量可以在客户端层面实现,利用保证同一时刻只有一个客户端访问共享变量,则服务端不需要额外加锁。如果允许多客户端同时访问共享变量,服务端分布式锁的实现会异常复杂。
lease也是通过客户端锁减轻服务端加锁的问题。分布式锁的损耗很大(ms级),如果经常访问的操作需要加分布式锁,会严重影响服务的性能。
库存,订单数量修改 加分布式锁的思路是将大量操作封装成事务,追求吞吐量。延迟要求高的操作应该避免服务加分布式锁,而是限制访问的客户端。
分布式元数据的另一种实现是自行实现raft/poixs协议直接保证元数据集群的一致性,同时提供高性能KV存储,该方法性能比申请加分布式锁高。
gpfs 可以控制client和server,采用client 锁和服务锁结合的形式。例如写目录文件/rename,gpfs要求某时刻只能有一个client执行操作。但修改文件元数据应该可以允许多client操作。
本文标题:并发
文章作者:Infinity
发布时间:2024-12-20
最后更新:2025-01-04
原始链接:https://larrystd.github.io/2024/12/20/%E5%B9%B6%E5%8F%91%E6%80%BB%E8%A7%88/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!