SakuraMotion 2021 - OS
type: channel
link: https://yingwu2021.xyz/index.php/category/OS/
items: 从 CPU 缓存模型说起
items: 怎么避免死锁？
items: 什么是悲观锁、乐观锁？
items: 进程、线程基础知识
items: 多线程冲突了怎么办？
items: 进程间有哪些通信方式？
items: 深度分页介绍及优化建议

从 CPU 缓存模型说起
type: item
date: 2025-06-20T13:56:00+08:00
description: AI 摘要 文章从CPU缓存与指令重排序切入，详解Java内存模型（JMM）：抽象主存与线程工作内存关系，定义8种原子操作及happens-before规则，保障多线程下共享变量可见性、有序性，并区分JMM与JVM内存区域，为并发编程提供统一规范。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结title: JMM（Java 内存模型）详解category: Javatag:Java并发head:metaname: keywordscontent: CPU 缓存模型,指令重排序,Java 内存模型（JMM）,happens-beforemetaname: descriptioncontent: 对于 Java 来说，你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范，除了抽象了线程和主内存之间的关系之外，其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范，其主要目的是为了简化多线程编程，增强程序可移植性的。JMM(Java 内存模型)主要定义了对于一个共享变量，当另一个线程对这个共享变量执行写操作后，这个线程对这个共享变量的可见性。要想理解透彻 JMM（Java 内存模型），我们先要从 CPU 缓存模型和指令重排序 说起！从 CPU 缓存模型说起为什么要弄一个 CPU 高速缓存呢？ 类比我们开发网站后台系统使用的缓存（比如 Redis）是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。我们甚至可以把 内存看作外存的高速缓存，程序运行的时候我们把外存的数据复制到内存，由于内存的处理速度远远高于外存，这样提高了处理速度。总结：CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题，内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。为了更好地理解，我画了一个简单的 CPU Cache 示意图如下所示。🐛 修正（参见：issue#1848）：对 CPU 缓存模型绘图不严谨的地方进行完善。现代的 CPU Cache 通常分为三层，分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache，这里不做讨论，并不常见CPU Cache 的工作方式： 先复制一份数据到 CPU Cache 中，当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据，当运算完成后，再将运算得到的数据写回 Main Memory 中。但是，这样存在 内存缓存不一致性的问题 ！比如我执行一个 i++ 操作的话，如果两个线程同时执行的话，假设两个线程从 CPU Cache 中读取的 i=1，两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2，而正确结果应该是 i=3。CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议（比如 MESI 协议）或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中，使用的缓存一致性协议通常也会有所不同。我们的程序运行在操作系统之上，操作系统屏蔽了底层硬件的操作细节，将各种硬件资源虚拟化。于是，操作系统也就同样需要解决内存缓存不一致性问题。操作系统通过 内存模型（Memory Model） 定义一系列规范来解决这个问题。无论是 Windows 系统，还是 Linux 系统，它们都有特定的内存模型。指令重排序说完了 CPU 缓存模型，我们再来看看另外一个比较重要的概念 指令重排序 。为了提升执行速度/性能，计算机在执行程序代码的时候，会对指令进行重排序。什么是指令重排序？ 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。常见的指令重排序有下面 2 种情况：编译器优化重排：编译器（包括 JVM、JIT 编译器等）在不改变单线程程序语义的前提下，重新安排语句的执行顺序。指令并行重排：现代处理器采用了指令级并行技术(Instruction-Level Parallelism，ILP)来将多条指令重叠执行。如果不存在数据依赖性，处理器可以改变语句对应机器指令的执行顺序。另外，内存系统也会有“重排序”，但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致，进而导致程序在多线程下执行可能出现问题。Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程，最终才变成操作系统可执行的指令序列。指令重排序可以保证串行语义一致，但是没有义务保证多线程间的语义也一致 ，所以在多线程下，指令重排序可能会导致一些问题。编译器和处理器的指令重排序的处理方式不一样。对于编译器，…

怎么避免死锁？
type: item
date: 2025-05-24T04:13:00+08:00
description: AI 摘要 文章介绍死锁概念、模拟代码、排查工具后，给出核心避免策略：资源有序分配法，即所有线程按统一顺序获取锁，破坏环路等待条件即可杜绝死锁。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结死锁的概念在多线程编程中，我们为了防止多线程竞争共享资源而导致数据错乱，都会在操作共享资源之前加上互斥锁，只有成功获得到锁的线程，才能操作共享资源，获取不到锁的线程就只能等待，直到锁被释放。那么，当两个线程为了保护两个不同的共享资源而使用了两个互斥锁，那么这两个互斥锁应用不当的时候，可能会造成两个线程都在等待对方释放锁，在没有外力的作用下，这些线程会一直相互等待，就没办法继续运行，这种情况就是发生了死锁。举个例子，樱舞拿了小美房间的钥匙，而樱舞在自己的房间里，小美拿了樱舞房间的钥匙，而小美也在自己的房间里。如果樱舞要从自己的房间里出去，必须拿到小美手中的钥匙，但是小美要出去，又必须拿到樱舞手中的钥匙，这就形成了死锁。死锁只有同时满足以下四个条件才会发生：互斥条件；持有并等待条件；不可剥夺条件；环路等待条件；互斥条件互斥条件是指多个线程不能同时使用同一个资源。比如下图，如果线程 A 已经持有的资源，不能再同时被线程 B 持有，如果线程 B 请求获取线程 A 已经占用的资源，那线程 B 只能等待，直到线程 A 释放了资源。持有并等待条件持有并等待条件是指，当线程 A 已经持有了资源 1，又想申请资源 2，而资源 2 已经被线程 C 持有了，所以线程 A 就会处于等待状态，但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。 不可剥夺条件不可剥夺条件是指，当线程已经持有了资源，在自己使用完之前不能被其他线程获取，线程 B 如果也想使用此资源，则只能在线程 A 使用完并释放后才能获取。环路等待条件环路等待条件指的是，在死锁发生的时候，两个线程获取资源的顺序构成了环形链。比如，线程 A 已经持有资源 2，而想请求资源 1，线程 B 已经获取了资源 1，而想请求资源 2，这就形成资源请求等待的环形图。模拟死锁问题的产生Talk is cheap. Show me the code.下面，我们用代码来模拟死锁问题的产生。首先，我们先创建 2 个线程，分别为线程 A 和 线程 B，然后有两个互斥锁，分别是 mutex_A 和 mutex_B，代码如下：pthread_mutex_t mutex_A = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutex_B = PTHREAD_MUTEX_INITIALIZER; int main() { pthread_t tidA, tidB; //创建两个线程 pthread_create(&tidA, NULL, threadA_proc, NULL); pthread_create(&tidB, NULL, threadB_proc, NULL); pthread_join(tidA, NULL); pthread_join(tidB, NULL); printf("exit\n"); return 0; }接下来，我们看下线程 A 函数做了什么。//线程函数 A void *threadA_proc(void *data) { printf("thread A waiting get ResourceA \n"); pthread_mutex_lock(&mutex_A); printf("thread A got ResourceA \n"); sleep(1); printf("thread A waiting get ResourceB \n"); pthread_mutex_lock(&mutex_B); printf("thread A got ResourceB \n"); pthread_mutex_unlock(&mutex_B); pthread_mutex_unlock(&mutex_A); return (void *)0; }可以看到，线程 A 函数的过程：先获取互斥锁 A，然后睡眠 1 秒；再获取互斥锁 B，然后释放互斥锁 B；最后释放互斥锁 A；//线程函数 B void *threadB_proc(void *data) { printf("thread B waiting get ResourceB \n"); pthread_mutex_lock(&mutex_B); printf("thread B got ResourceB \n"); sleep(1); printf("thread B waiting get ResourceA \n"); pthread_mutex_lock(&mutex_A)…

什么是悲观锁、乐观锁？
type: item
date: 2025-03-14T06:55:00+08:00
description: AI 摘要 悲观锁假定冲突频繁，访问共享资源前先加锁；乐观锁假定冲突极少，先操作后验证，冲突则重试。前者含互斥、自旋、读写锁，后者无锁，靠版本号或CAS检测冲突，仅适合低冲突场景。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结什么是悲观锁、乐观锁？生活中用到的锁，用途都比较简单粗暴，上锁基本是为了防止外人进来、电动车被偷等等。但生活中也不是没有 BUG 的，比如加锁的电动车在「广西 - 窃·格瓦拉」面前，锁就是形同虚设，只要他愿意，他就可以轻轻松松地把你电动车给「顺走」，不然打工怎么会是他这辈子不可能的事情呢？牛逼之人，必有牛逼之处。那在编程世界里，「锁」更是五花八门，多种多样，每种锁的加锁开销以及应用场景也可能会不同。如何用好锁，也是程序员的基本素养之一了。高并发的场景下，如果选对了合适的锁，则会大大提高系统的性能，否则性能会降低。所以，知道各种锁的开销，以及应用场景是很有必要的。接下来，就谈一谈常见的这几种锁：多线程访问共享资源的时候，避免不了资源竞争而导致数据错乱的问题，所以我们通常为了解决这一问题，都会在访问共享资源之前加锁。最常用的就是互斥锁，当然还有很多种不同的锁，比如自旋锁、读写锁、乐观锁等，不同种类的锁自然适用于不同的场景。如果选择了错误的锁，那么在一些高并发的场景下，可能会降低系统的性能，这样用户体验就会非常差了。所以，为了选择合适的锁，我们不仅需要清楚知道加锁的成本开销有多大，还需要分析业务场景中访问的共享资源的方式，再来还要考虑并发访问共享资源时的冲突概率。对症下药，才能减少锁对高并发性能的影响。那接下来，针对不同的应用场景，谈一谈「互斥锁、自旋锁、读写锁、乐观锁、悲观锁」的选择和使用。互斥锁与自旋锁最底层的两种就是会「互斥锁和自旋锁」，有很多高级的锁都是基于它们实现的，你可以认为它们是各种锁的地基，所以我们必须清楚它俩之间的区别和应用。加锁的目的就是保证共享资源在任意时间里，只有一个线程访问，这样就可以避免多线程导致共享数据错乱的问题。当已经有一个线程加锁后，其他线程加锁则就会失败，互斥锁和自旋锁对于加锁失败后的处理方式是不一样的：互斥锁加锁失败后，线程会释放 CPU ，给其他线程；自旋锁加锁失败后，线程会忙等待，直到它拿到锁；互斥锁是一种「独占锁」，比如当线程 A 加锁成功后，此时互斥锁已经被线程 A 独占了，只要线程 A 没有释放手中的锁，线程 B 加锁就会失败，于是就会释放 CPU 让给其他线程，既然线程 B 释放掉了 CPU，自然线程 B 加锁的代码就会被阻塞。对于互斥锁加锁失败而阻塞的现象，是由操作系统内核实现的。当加锁失败时，内核会将线程置为「睡眠」状态，等到锁被释放后，内核会在合适的时机唤醒线程，当这个线程成功获取到锁后，于是就可以继续执行。如下图：所以，互斥锁加锁失败时，会从用户态陷入到内核态，让内核帮我们切换线程，虽然简化了使用锁的难度，但是存在一定的性能开销成本。那这个开销成本是什么呢？会有两次线程上下文切换的成本：当线程加锁失败时，内核会把线程的状态从「运行」状态设置为「睡眠」状态，然后把 CPU 切换给其他线程运行；接着，当锁被释放时，之前「睡眠」状态的线程会变为「就绪」状态，然后内核会在合适的时间，把 CPU 切换给该线程运行。线程的上下文切换的是什么？当两个线程是属于同一个进程，因为虚拟内存是共享的，所以在切换时，虚拟内存这些资源就保持不动，只需要切换线程的私有数据、寄存器等不共享的数据。上下文切换的耗时有大佬统计过，大概在几十纳秒到几微秒之间，如果你锁住的代码执行时间比较短，那可能上下文切换的时间都比你锁住的代码执行时间还要长。所以，如果你能确定被锁住的代码执行时间很短，就不应该用互斥锁，而应该选用自旋锁，否则使用互斥锁。自旋锁是通过 CPU 提供的 CAS 函数（Compare And Swap），在「用户态」完成加锁和解锁操作，不会主动产生线程上下文切换，所以相比互斥锁来说，会快一些，开销也小一些。一般加锁的过程，包含两个步骤：第一步，查看锁的状态，如果锁是空闲的，则执行第二步；第二步，将锁设置为当前线程持有；CAS 函数就把这两个步骤合并成一条硬件级指令，形成原子指令，这样就保证了这两个步骤是不可分割的，要么一次性执行完两个步骤，要么两个步骤都不执行。比如，设锁为变量 lock，整数 0 表示锁是空闲状态，整数 pid 表示线程 ID，那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作，CAS(lock, pid, 0) 则表示解锁操作。使用自旋锁的时候，当发生多线程竞争锁的情况，加锁失败的线程会「忙等待」，直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现，不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」，因为可以…

进程、线程基础知识
type: item
date: 2024-08-06T02:30:00+08:00
description: AI 摘要 文章系统梳理了进程与线程的核心概念：进程是资源分配单位，有创建、就绪、运行、阻塞、挂起、结束等状态及PCB控制块；线程是CPU调度单位，共享进程资源，分用户、内核、LWP三种实现。并详解上下文切换、调度时机与FCFS、SJF、RR、HRRN、HPF、MFQ等算法。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结进程我们编写的代码只是一个存储在硬盘的静态文件，通过编译后就会生成二进制可执行文件，当我们运行这个可执行文件后，它会被装载到内存中，接着 CPU 会执行程序中的每一条指令，那么这个运行中的程序，就被称为「进程」（Process）。现在我们考虑有一个会读取硬盘文件数据的程序被执行了，那么当运行到读取文件的指令时，就会去从硬盘读取数据，但是硬盘的读写速度是非常慢的，那么在这个时候，如果 CPU 傻傻的等硬盘返回数据的话，那 CPU 的利用率是非常低的。做个类比，你去煮开水时，你会傻傻的等水壶烧开吗？很明显，小孩也不会傻等。我们可以在水壶烧开之前去做其他事情。当水壶烧开了，我们自然就会听到“嘀嘀嘀”的声音，于是再把烧开的水倒入到水杯里就好了。所以，当进程要从硬盘读取数据时，CPU 不需要阻塞等待数据的返回，而是去执行另外的进程。当硬盘数据返回时，CPU 会收到个中断，于是 CPU 再继续运行这个进程。这种多个程序、交替执行的思想，就有 CPU 管理多个进程的初步想法。对于一个支持多进程的系统，CPU 会从一个进程快速切换至另一个进程，其间每个进程各运行几十或几百个毫秒。虽然单核的 CPU 在某一个瞬间，只能运行一个进程。但在 1 秒钟期间，它可能会运行多个进程，这样就产生并行的错觉，实际上这是并发。并发和并行有什么区别？一图胜千言。进程与程序的关系的类比到了晚饭时间，一对小情侣肚子都咕咕叫了，于是男生见机行事，就想给女生做晚饭，所以他就在网上找了辣子鸡的菜谱，接着买了一些鸡肉、辣椒、香料等材料，然后边看边学边做这道菜。突然，女生说她想喝可乐，那么男生只好把做菜的事情暂停一下，并在手机菜谱标记做到哪一个步骤，把状态信息记录了下来。然后男生听从女生的指令，跑去下楼买了一瓶冰可乐后，又回到厨房继续做菜。这体现了，CPU 可以从一个进程（做菜）切换到另外一个进程（买可乐），在切换前必须要记录当前进程中运行的状态信息，以备下次切换回来的时候可以恢复执行。所以，可以发现进程有着「运行 - 暂停 - 运行」的活动规律。进程的状态在上面，我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来，一个进程并不是自始至终连续不停地运行的，它与并发执行中的其他进程的执行是相互制约的。它有时处于运行状态，有时又由于某种原因而暂停运行处于等待状态，当使它暂停的原因消失后，它又进入准备运行状态。所以，在一个进程的活动期间至少具备三种基本状态，即运行状态、就绪状态、阻塞状态。上图中各个状态的意义：运行状态（Running）：该时刻进程占用 CPU；就绪状态（Ready）：可运行，由于其他进程处于运行状态而暂时停止运行；阻塞状态（Blocked）：该进程正在等待某一事件发生（如等待输入/输出操作的完成）而暂时停止运行，这时，即使给它 CPU 控制权，它也无法运行；当然，进程还有另外两个基本状态：创建状态（new）：进程正在被创建时的状态；结束状态（Exit）：进程正在从系统中消失时的状态；于是，一个完整的进程状态的变迁如下图：再来详细说明一下进程的状态变迁：NULL -> 创建状态：一个新进程被创建时的第一个状态；创建状态 -> 就绪状态：当进程被创建完成并初始化后，一切就绪准备运行时，变为就绪状态，这个过程是很快的；就绪态 -> 运行状态：处于就绪状态的进程被操作系统的进程调度器选中后，就分配给 CPU 正式运行该进程；运行状态 -> 结束状态：当进程已经运行完成或出错时，会被操作系统作结束状态处理；运行状态 -> 就绪状态：处于运行状态的进程在运行过程中，由于分配给它的运行时间片用完，操作系统会把该进程变为就绪态，接着从就绪态选中另外一个进程运行；运行状态 -> 阻塞状态：当进程请求某个事件且必须等待时，例如请求 I/O 事件；阻塞状态 -> 就绪状态：当进程要等待的事件完成时，它从阻塞状态变到就绪状态；如果有大量处于阻塞状态的进程，进程可能会占用着物理内存空间，显然不是我们所希望的，毕竟物理内存空间是有限的，被阻塞状态的进程占用着物理内存就是一种浪费物理内存的行为。所以，在虚拟内存管理的操作系统中，通常会把阻塞状态的进程的物理内存空间换出到硬盘，等需要再次运行的时候，再从硬盘换入到物理内存。那么，就需要一个新的状态，来描述进程没有占用实际的物理内存空间的情况，这个状态就是挂起状态。这跟阻塞状态是不一样，阻塞状态是等待某个事…

多线程冲突了怎么办？
type: item
date: 2024-07-05T06:39:00+08:00
description: AI 摘要 文章剖析多线程竞争共享资源导致的“竞争条件”，提出用“临界区—互斥—同步”思路解决；详解锁（自旋/无等待）、信号量（PV）及生产者-消费者、哲学家就餐、读者-写者等经典模型的多种实现，给出免死锁、高并发、公平等策略。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结竞争与协作在单核 CPU 系统里，为了实现多个程序同时运行的假象，操作系统通常以时间片调度的方式，让每个进程执行每次执行一个时间片，时间片用完了，就切换下一个进程运行，由于这个时间片的时间很短，于是就造成了「并发」的现象。另外，操作系统也为每个进程创建巨大、私有的虚拟内存的假象，这种地址空间的抽象让每个程序好像拥有自己的内存，而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。如果一个程序只有一个执行流程，也代表它是单线程的。当然一个程序可以有多个执行流程，也就是所谓的多线程程序，线程是调度的基本单位，进程则是资源分配的基本单位。所以，线程之间是可以共享进程的资源，比如代码段、堆空间、数据段、打开的文件等资源，但每个线程都有自己独立的栈空间。那么问题就来了，多个线程如果竞争共享资源，如果不采取有效的措施，则会造成共享数据的混乱。我们做个小实验，创建两个线程，它们分别对共享变量 i 自增 1 执行 10000 次，如下代码（虽然说是 C++ 代码，但是没学过 C++ 的同学也是看到懂的）：按理来说，i 变量最后的值应该是 20000，但很不幸，并不是如此。我们对上面的程序执行一下：运行了两次，发现出现了 i 值的结果是 15173，也会出现 20000 的 i 值结果。每次运行不但会产生错误，而且得到不同的结果。在计算机里是不能容忍的，虽然是小概率出现的错误，但是小概率事件它一定是会发生的，「墨菲定律」大家都懂吧。为什么会发生这种情况？为了理解为什么会发生这种情况，我们必须了解编译器为更新计数器 i 变量生成的代码序列，也就是要了解汇编指令的执行顺序。在这个例子中，我们只是想给 i 加上数字 1，那么它对应的汇编指令执行过程是这样的：可以发现，只是单纯给 i 加上数字 1，在 CPU 运行的时候，实际上要执行 3 条指令。设想我们的线程 1 进入这个代码区域，它将 i 的值（假设此时是 50）从内存加载到它的寄存器中，然后它向寄存器加 1，此时在寄存器中的 i 值是 51。现在，一件不幸的事情发生了：时钟中断发生。因此，操作系统将当前正在运行的线程的状态保存到线程的线程控制块 TCB。现在更糟的事情发生了，线程 2 被调度运行，并进入同一段代码。它也执行了第一条指令，从内存获取 i 值并将其放入到寄存器中，此时内存中 i 的值仍为 50，因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执行接下来的两条指令，将寄存器中的 i 值 + 1，然后将寄存器中的 i 值保存到内存中，于是此时全局变量 i 值是 51。最后，又发生一次上下文切换，线程 1 恢复执行。还记得它已经执行了两条汇编指令，现在准备执行最后一条指令。回忆一下，线程 1 寄存器中的 i 值是 51，因此，执行最后一条指令后，将值保存到内存，全局变量 i 的值再次被设置为 51。简单来说，增加 i（值为 50）的代码被运行两次，按理来说，最后的 i 值应该是 52，但是由于不可控的调度，导致最后 i 值却是 51。针对上面线程 1 和线程 2 的执行过程，我画了一张流程图，会更明确一些：互斥的概念上面展示的情况称为竞争条件（race condition），当多线程相互竞争操作共享变量时，由于运气不好，即在执行过程中发生了上下文切换，我们得到了错误的结果，事实上，每次运行都可能得到不同的结果，因此输出的结果存在不确定性（indeterminate）。由于多线程执行操作共享变量的这段代码可能会导致竞争状态，因此我们将此段代码称为临界区（critical section），它是访问共享资源的代码片段，一定不能给多线程同时执行。我们希望这段代码是互斥（mutualexclusion）的，也就说保证一个线程在临界区执行时，其他线程应该被阻止进入临界区，说白了，就是这段代码执行过程中，最多只能出现一个线程。另外，说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候，也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。同步的概念互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的，只要一个进程/线程进入了临界区，其他试图想进入临界区的进程/线程都会被阻塞着，直到第一个进程/线程离开了临界区。我们都知道在多线程里，每个线程并不一定是顺序执行的，它们基本是以各自独立的、不可预知的速度向前推进，但有时候我们又希望多个线程能密切合作，以实现一个共同…

进程间有哪些通信方式？
type: item
date: 2024-05-13T04:54:00+08:00
description: AI 摘要 Linux进程间通信方式：匿名/命名管道、消息队列、共享内存、信号量、信号、Socket；线程共享进程资源，用全局变量及信号量实现互斥同步。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结每个进程的用户地址空间都是独立的，一般而言是不能互相访问的，但内核空间是每个进程都共享的，所以进程之间要通信必须通过内核。Linux 内核提供了不少进程间通信的机制，我们来一起瞧瞧有哪些？管道如果你学过 Linux 命令，那你肯定很熟悉「|」这个竖线。$ ps auxf | grep mysql上面命令行里的「|」竖线就是一个管道，它的功能是将前一个命令（ps auxf）的输出，作为后一个命令（grep mysql）的输入，从这功能描述，可以看出管道传输数据是单向的，如果想相互通信，我们需要创建两个管道才行。同时，我们得知上面这种管道是没有名字，所以「|」表示的管道称为匿名管道，用完了就销毁。管道还有另外一个类型是命名管道，也被叫做 FIFO，因为数据是先进先出的传输方式。在使用命名管道前，先需要通过 mkfifo 命令来创建，并且指定管道名字：$ mkfifo myPipemyPipe 就是这个管道的名称，基于 Linux 一切皆文件的理念，所以管道也是以文件的方式存在，我们可以用 ls 看一下，这个文件的类型是 p，也就是 pipe（管道）的意思：$ ls -l prw-r--r--. 1 root root 0 Jul 17 02:45 myPipe接下来，我们往 myPipe 这个管道写入数据：$ echo "hello" > myPipe // 将数据写进管道 // 停住了 ...你操作了后，你会发现命令执行后就停在这了，这是因为管道里的内容没有被读取，只有当管道里的数据被读完后，命令才可以正常退出。于是，我们执行另外一个命令来读取这个管道里的数据：$ cat < myPipe // 读取管道里的数据 hello可以看到，管道里的内容被读取出来了，并打印在了终端上，另外一方面，echo 那个命令也正常退出了。我们可以看出，管道这种通信方式效率低，不适合进程间频繁地交换数据。当然，它的好处，自然就是简单，同时也我们很容易得知管道里的数据已经被另一个进程读取了。那管道如何创建呢，背后原理是什么？匿名管道的创建，需要通过下面这个系统调用：int pipe(int fd[2])这里表示创建一个匿名管道，并返回了两个描述符，一个是管道的读取端描述符 fd[0]，另一个是管道的写入端描述符 fd[1]。注意，这个匿名管道是特殊的文件，只存在于内存，不存于文件系统中。其实，所谓的管道，就是内核里面的一串缓存。从管道的一段写入的数据，实际上是缓存在内核中的，另一端读取，也就是从内核中读取这段数据。另外，管道传输的数据是无格式的流且大小受限。看到这，你可能会有疑问了，这两个描述符都是在一个进程里面，并没有起到进程间通信的作用，怎么样才能使得管道是跨过两个进程的呢？我们可以使用 fork 创建子进程，创建的子进程会复制父进程的文件描述符，这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」，两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。管道只能一端写入，另一端读出，所以上面这种模式容易造成混乱，因为父进程和子进程都可以同时写入，也都可以读出。那么，为了避免这种情况，通常的做法是：父进程关闭读取的 fd[0]，只保留写入的 fd[1]；子进程关闭写入的 fd[1]，只保留读取的 fd[0]；所以说如果需要双向通信，则应该创建两个管道。到这里，我们仅仅解析了使用管道进行父进程与子进程之间的通信，但是在我们 shell 里面并不是这样的。在 shell 里面执行 A | B命令的时候，A 进程和 B 进程都是 shell 创建出来的子进程，A 和 B 之间不存在父子关系，它俩的父进程都是 shell。所以说，在 shell 里通过「|」匿名管道将多个命令连接在一起，实际上也就是创建了多个子进程，那么在我们编写 shell 脚本时，能使用一个管道搞定的事情，就不要多用一个管道，这样可以减少创建子进程的系统开销。我们可以得知，对于匿名管道，它的通信范围是存在父子关系的进程。因为管道没有实体，也就是没有管道文件，只能通过 fork 来复制父进程 fd 文件描述符，来达到通信的目的。另外，对于命名管道，它可以在不相关的进程间也能相互通信。因为命令管道，提前创建了一个类型为管道的设备文件，在进程里只要使用这个设备文件，就可以相互通信。不管是匿名管道还是命名管道，进程写入的数据都是缓存在内核中，另一个进程读取数据时候自然也是从内核中获取，同时通信数据都遵循先进先出原则，不支持 lseek 之类的文件定位操作。消息队…

深度分页介绍及优化建议
type: item
date: 2024-01-28T08:30:00+08:00
description: AI 摘要 文章剖析MySQL深度分页性能瓶颈，提出范围查询、子查询、INNER JOIN延迟关联、覆盖索引四类优化方案，并指出各方案适用场景与局限，帮助开发者减少回表与随机IO，提升大数据分页效率。 此摘要根据文章内容自动生成，仅用于文章内容的解释与总结title: 深度分页介绍及优化建议category: 高性能head:metaname: keywordscontent: 深度分页metaname: descriptioncontent: 查询偏移量过大的场景我们称为深度分页，这会导致查询性能较低。深度分页可以采用范围查询、子查询、INNER JOIN 延迟关联、覆盖索引等方法进行优化。深度分页介绍查询偏移量过大的场景我们称为深度分页，这会导致查询性能较低，例如：# MySQL 在无法利用索引的情况下跳过1000000条记录后，再获取10条记录 SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10深度分页优化建议这里以 MySQL 数据库为例介绍一下如何优化深度分页。范围查询当可以保证 ID 的连续性时，根据 ID 范围进行分页是比较好的解决方案：# 查询指定 ID 范围的数据 SELECT * FROM t_order WHERE id > 100000 AND id <= 100010 ORDER BY id # 也可以通过记录上次查询结果的最后一条记录的ID进行下一页的查询： SELECT * FROM t_order WHERE id > 100000 LIMIT 10这种优化方式限制比较大，且一般项目的 ID 也没办法保证完全连续。子查询我们先查询出 limit 第一个参数对应的主键值，再根据这个主键值再去过滤并 limit，这样效率会更快一些。阿里巴巴《Java 开发手册》中也有对应的描述：利用延迟关联或者子查询优化超多分页场景。# 通过子查询来获取 id 的起始值，把 limit 1000000 的条件转移到子查询 SELECT * FROM t_order WHERE id >= (SELECT id FROM t_order limit 1000000, 1) LIMIT 10;不过，子查询的结果会产生一张新表，会影响性能，应该尽量避免大量使用子查询。并且，这种方法只适用于 ID 是正序的。在复杂分页场景，往往需要通过过滤条件，筛选到符合条件的 ID，此时的 ID 是离散且不连续的。当然，我们也可以利用子查询先去获取目标分页的 ID 集合，然后再根据 ID 集合获取内容，但这种写法非常繁琐，不如使用 INNER JOIN 延迟关联。延迟关联延迟关联的优化思路，跟子查询的优化思路其实是一样的：都是把条件转移到主键索引树，减少回表的次数。不同点是，延迟关联使用了 INNER JOIN（内连接） 包含子查询。SELECT t1.* FROM t_order t1 INNER JOIN (SELECT id FROM t_order limit 1000000, 10) t2 ON t1.id = t2.id;除了使用 INNER JOIN 之外，还可以使用逗号连接子查询。SELECT t1.* FROM t_order t1, (SELECT id FROM t_order limit 1000000, 10) t2 WHERE t1.id = t2.id;覆盖索引索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。覆盖索引的好处：避免 InnoDB 表进行索引的二次查询，也就是回表操作: InnoDB 是以聚集索引的顺序来存储的，对于 InnoDB 来说，二级索引在叶子节点中所保存的是行的主键信息，如果是用二级索引查询数据的话，在查找到相应的键值后，还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中，二级索引的键值中可以获取所有的数据，避免了对主键的二次查询（回表），减少了 IO 操作，提升了查询效率。可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的，对于 IO 密集型的范围查找来说，对比随机从磁盘读取每一行的数据 IO 要少的多，因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。# 如果只需要查询 id, code, type 这三列，可建立 code 和 type 的覆盖索引 SELECT id, code, type FROM t_order ORDER BY code LIMIT 1000000, 10;不过，当查询的结果集占表的总行数的很大一部分时，可能就不会走索引了，自动转换为全表扫描。当然了，也可以通过 FORCE INDEX 来强制查询优化器走索引，但…