• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2024-09-13 19:02 Aet 隐藏边栏 |   抢沙发  46 
文章评分 1 次,平均分 5.0

示例代码

WinDbg分析

启动调试

  1. 编译并运行程序
    1. 使用VS编译生成test.exe,并双击运行
  2. 附加到进程
    1. 启动 WinDbg,选择 File > Attach to Process,找到并选择运行的死锁程序
  3. 暂停程序执行
    1. WinDbg 中,点击工具栏上的暂停按钮 Debug -> Break

分析:检查线程状态

  1. 输入以下命令查看当前所有线程

  1. 使用以下命令查看当前线程的调用堆栈

  1. 示例

  1. 分析线程4
    1. 调用堆栈显示,线程4卡在了等待互斥锁的过程中,从堆栈顶端可以看出线程在 NtWaitForAlertByThreadId 上挂起,这通常表示线程正在等待某个锁的释放
    2. MSVCP140D!mtx_do_lock+0xb3MSVCP140D!_Mtx_lock+0x15 表示线程正在试图锁定一个互斥锁
    3. leetcode_temp!std::_Mutex_base::lock+0x34leetcode_temp!std::lock_guard<std::mutex>::lock_guard<std::mutex>+0x45
      显示代码正在使用 std::mutexstd::lock_guard 进行锁操作,这些代码出现在死锁的关键位置
    4. 堆栈显示锁的操作发生在文件 leetcode_temp.cpp 的第 18 行,也就是 threadFunc1 函数中
  2. 分析线程5
    1. 表现和线程4一样
    2. 线程5也卡在了等待互斥锁的过程中(线程正在等待某个锁的释放)
    3. 和线程4一样,线程5正在试图锁定一个互斥锁
    4. 和线程4一样,线程5也在使用 std::mutexstd::lock_guard 进行锁操作,这些代码出现在死锁的关键位置
    5. 不同的是,堆栈显示锁的操作发生在文件 leetcode_temp.cpp 的第 25行,也就是 threadFunc2 函数中
  3. 结论
    1. 观察到在线程4和线程5中看到了锁相关内容
    2. threadFunc1是线程4的入口函数,threadFunc2是线程5的入口函数
    3. 在这两个函数中,都是先使用std::lock_guard<std::mutex>锁了一个std::mutex,然后在等待另一个锁的释放
    4. 可以得出死锁发生在了这两个函数中

进一步查看线程信息

  1. 在上一步确定它们发生了死锁的情况下,进一步验证
    1. 切到目标线程

  1. 查看堆栈

  1. 发现第6条堆栈信息,表示是 刚进入线程
    1. 查看局部变量以及变量地址
    2. 也可以直接查看全局变量(知道两个互斥量是全局的情况下,见下文)

  1. 知道了两个锁的地址后,看看在线程4里面等待的是哪一个锁
    1. 可以看到是0x00007ff7ab783050这个,也就是线程4等待的是lock2

  1. 同理,可以看到线程5里面等待的是0x00007ff7ab783000,也就是lock1

查看全局变量

  1. 查看所有符号

  1. 查看mutex相关符号

查看锁被哪个线程占有

  1. 还是像之前一样,先切到线程开始的地方,具体查看两个锁

  1. 点击lock1对象的内部数据,可以看到:
    1. 它被线程21996(10进制)占有

  1. 可以看到21996线程占用了lock1
    1. 16进制进行转换,是55ec线程占有了lock1

  1. 查看所有线程
    1. 可以看到,windbg里面的4号线程就是占有了lock155ec线程
    2. 66d8表示的是进程

  1. 用同样的方法
    1. 可以看到3124线程(也就是windbg里面的5号线程)占有了lock2

综上所述

  1. windbg4号线程占有了lock1,在等待lock2
  2. windbg5号线程占有了lock2, 在等待lock1
  3. 所以它们发生了死锁

其他分析:检查锁

  1. 使用以下命令查找每个线程的持有和等待锁的信息
    1. 这个命令是 WinDbgext.dll 扩展的命令,用于显示当前进程中所有锁的状态

  1. 示例:

  1. 结论
    1. 并没有看到相关信息
    2. !locks 主要是针对临界区(Critical Sections)进行分析
    3. 如果你的程序使用了其他同步机制(如 std::mutex 或其他自定义锁),!locks 可能无法正确识别这些锁

使用其他调试工具继续分析

  1. 对于标准的 C++ 互斥锁(如 std::mutex),可以使用其他调试扩展
    1. 如:!analyze -v:尝试自动分析可能的死锁和线程问题
  2. 示例:

  1. 示例信息解析:
    1. 00007ffc4a291090 (ntdll!DbgBreakPoint)
      表示程序在调试器的中断点处停止
      这通常是因为调试器手动插入了一个中断来暂停程序执行
    2. 80000003 (Break instruction exception)
      表示一个断点异常,这通常由调试器触发,并不是因为程序的错误逻辑,而是调试器通过插入断点来中止程序执行
    3. Analysis.CPU.mSec: 1812
      表示 CPU 花费了 1812 毫秒进行当前分析
    4. Analysis.Elapsed.mSec: 1801
      表示整个分析过程花费了 1801 毫秒
    5. Timeline.OS.Boot.DeltaSec: 20061
      自上次系统启动以来的秒数,表明系统已经运行了大约 5.5 小时
    6. Timeline.Process.Start.DeltaSec: 1659
      自进程启动以来的秒数,表明程序运行了大约 27 分钟后进入调试状态
    7. Process Name: leetcode_temp.exe
      这是当前正在调试的程序名称
    8. Module Name: ntdll
      引发断点异常的模块是 ntdll.dllWindows 核心模块之一,常见于处理低级别的系统调用
    9. Image Name: ntdll.dll
      这是导致异常的模块的镜像名称
    10. Symbol Name: ntdll!DbgBreakPoint+0
      显示异常是由 ntdll 模块中的 DbgBreakPoint 函数引发的
    11. Failure Bucket ID: BREAKPOINT_80000003_ntdll.dll!DbgBreakPoint
      这个 Bucket ID 用于标识调试报告中的断点异常,是一种分类标识符,帮助 Microsoft 分析错误报告
    12. Stack Text: 显示了堆栈的调用链:

其他总结

关于程序的死锁

  1. 一个程序发生死锁后,如果没有其他线程在执行任务,那么该程序的 CPU 使用率通常会接近 0%
    1. 死锁发生时,涉及死锁的线程会进入阻塞状态(例如等待互斥锁、信号量等资源的释放)
    2. 被阻塞的线程不会进行任何活动,因为它们处于等待状态,不会占用 CPU 时间
    3. 虽然 CPU 使用率为 0%,但程序可能仍然占用其他系统资源(如内存、文件句柄等),这些资源在死锁解除前不会被释放
  2. 一个线程进入(死锁、等待消息、等待某内核对象)阻塞状态意味着什么
    1. 意味着该线程不会向 CPU 提出任何执行请求

关于程序的死循环

  1. CPU12 个物理核心和 24 个逻辑核心(线程)
    1. 通常这表示每个物理核心有 2 个逻辑线程(即超线程技术)
    2. 每个逻辑核心可以独立执行任务
  2. 当程序进入死循环时,它的执行线程将不断占用 CPU 资源进行计算,不会释放控制权
    1. 这个线程会独占一个逻辑核心的所有计算资源
  3. 死循环和时间片
    1. 即使是处于死循环的线程,它的执行依然受到操作系统调度器的控制,具体表现为线程会被分配一个时间片
    2. 当时间片耗尽时,调度器会中断该线程的执行,将其挂起并选择下一个就绪的线程执行
    3. 死循环线程的特性是它在每次被调度到 CPU 执行时,会完全利用分配给它的时间片,不会主动释放控制权(因为它没有阻塞、等待等操作)
    4. 当它的时间片用尽时,虽然会暂时被挂起,但由于它仍然在就绪队列中,调度器很快会再次选择它执行
    5. 由于每次被调度到时,它都会立刻占用 CPU 资源进行计算,因此它会反复不断地占满分配给它的计算资源

关于GUI 程序消息循环

  1. GUI 程序通常包含一个消息循环,它的运行机制可以被理解为一个持续运行的循环(类似死循环)
    1. 但这种消息循环与真正的“死循环”是有所不同的
  2. 消息循环虽然看起来是一个“死循环”,但它通常不会占用大量的 CPU 资源
    1. 因为在没有消息需要处理时,它会进入等待状态(例如 GetMessage 函数会阻塞)
    2. 关于等待状态,见上一条关于程序的死锁

线程的工作机制

  1. 概述
    1. 线程是程序的基本执行单元,是调度和分配 CPU 时间的基本实体
    2. 一个程序可以包含一个或多个线程,每个线程独立执行
  2. 线程状态
    1. 新建状态(New):线程被创建但未开始运行
    2. 就绪状态(Ready):线程准备好执行,等待操作系统分配 CPU
    3. 运行状态(Running):线程被 CPU 调度器分配时间片,正在执行
    4. 阻塞状态(Blocked/Waiting):线程因等待资源或事件而暂停执行,不占用 CPU
    5. 终止状态(Terminated):线程完成了执行或因异常终止
  3. 线程如何向 CPU 提出执行请求
    1. 就绪队列
      当线程创建并进入就绪状态时,它会被放入操作系统的就绪队列中
      就绪队列中的线程表示它们已经准备好执行,等待 CPU 分配时间片
    2. 调度请求
      线程向 CPU 提出执行请求的本质是被操作系统的调度器从就绪队列中选择并分配 CPU 时间片
      线程本身不会主动请求 CPU,而是由调度器根据调度算法(如轮询调度、优先级调度等)来决定哪个线程可以执行
    3. 线程的状态转换
      就绪到运行:当调度器选择某个线程执行时,线程从就绪状态切换到运行状态
      运行到阻塞:线程因等待 I/O 操作、锁定资源或等待事件进入阻塞状态
      阻塞到就绪:当等待条件满足(如 I/O 完成、资源释放),线程返回就绪队列等待重新调度
  4. CPU 如何响应线程的执行请求
    1. CPU 调度器
      CPU 调度器是操作系统内核的一部分,负责在就绪队列中选择线程,将 CPU 时间片分配给它们
    2. 时间片
      每个线程在被调度到 CPU 上时会分配一个时间片
      时间片结束后,线程会被挂起,切换到另一个线程
      通过时间片轮转,CPU 可以在多个线程之间快速切换,形成线程“并发”执行的效果
    3. 中断和抢占
      CPU 调度依赖于时钟中断,时钟中断是调度器抢占正在运行的线程并选择下一个线程执行的时机
      抢占机制确保高优先级的线程可以优先执行,即使它们在低优先级线程的时间片内出现
  5. 调度器的工作机制如下:
    1. 上下文切换(Context Switch
    2. 当调度器决定让某个线程运行时,会执行上下文切换
    3. 上下文切换包括保存当前运行线程的状态(寄存器、程序计数器等),然后加载即将运行的线程的状态
  6. 理解:
    1. 程序 A 的线程可以被CPU的任何一个逻辑核心执行,具体由操作系统的调度器决定
    2. 而任何一个逻辑核心在执行某个线程时,都可以通过线程本地存储(Thread Local Storage, TLS)和线程的上下文信息来加载并恢复该线程的最后一次执行状态,从而继续执行之前未完成的任务
    3. 总体来讲,每个逻辑核心虽然任何时刻只能执行一个线程,但由于时间片分配非常短(通常在几毫秒级别),逻辑核心会很快得频繁切换执行不同的线程,使得我们感知不到这个交替过程

CPU 执行线程的详细过程

  1. 线程上下文保存与恢复
    1. 当一个线程的时间片结束或被调度器挂起时,操作系统会保存该线程的上下文信息
      包括:
      寄存器状态(如通用寄存器、程序计数器、栈指针等)
      线程本地存储(TLS):用于存储线程特定的数据
      线程栈:保存函数调用、局部变量、返回地址等
    2. 当线程被重新调度时,这些保存的上下文会被加载到 CPU 的寄存器中,使得线程可以从上次暂停的位置继续执行
  2. 逻辑核心执行线程的机制
    1. 当调度器决定让一个线程在某个逻辑核心上运行时,CPU 会通过上下文切换机制恢复线程的执行状态
    2. CPU 将寄存器恢复到该线程最后一次保存的状态,包括程序计数器(PC)的位置,这个计数器指向下一条待执行的指令地址
  3. 指令的获取和执行
    1. 取指令,如下:
    2. 程序计数器中的值通过 地址总线 被发送到系统的内存控制器
      (通常是 RAM 中的某个物理地址,也就是程序的代码段)
    3. 内存控制器接收地址请求后,通过 数据总线 返回该地址处存储的指令(通常是机器码形式的指令)
  4. 指令的解码与执行
    1. 解码(Decode
      CPU 解析指令,确定要执行的操作类型(例如算术运算、逻辑运算、内存访问等)
    2. 执行(Execute
      CPU 执行指令,完成指令中指定的操作(如寄存器之间的数据传输、内存读写、算术逻辑运算等)
  5. 指令结果的处理
    1. 执行结果可能会更新 CPU 的寄存器、改变内存中的数据,或者修改程序计数器的值以指向下一条指令
  6. 循环重复执行
    1. 这一过程会不断重复:取指令、解码、执行、更新,直到时间片结束或线程被中断
  7. 总结:
    1. 当调度器决定让一个线程在某个逻辑核心上运行时,CPU 会通过上下文切换机制恢复线程的执行状态
    2. 然后,程序计数器中的值通过 地址总线 被发送到系统的内存控制器
    3. 内存控制器接收地址请求后,通过 数据总线 返回该地址处存储的指令
    4. 在指令通过数据总线传输到 CPU 之前,内存控制器会发出一个“数据就绪”信号,告知 CPU 数据已经准备好
    5. CPU 接收到“数据就绪”信号之前,可能会进入短暂的等待状态,确保不会在数据未传输完成时开始操作
    6. 一旦数据总线上的指令准备好,CPU 会锁存(Latch)这些数据到内部指令寄存器(Instruction Register, IR)中
    7. CPU 的时钟信号与控制总线的“数据就绪”信号同步,以确保数据被正确接收
    8. CPU 会检查控制信号的状态,以确定数据传输是否成功,例如通过错误检测信号(如奇偶校验、ECC 检查)来确保数据完整性
    9. CPU 将指令从指令寄存器中读取,并通过解码单元解析该指令
    10. 根据解码结果,执行相应的操作(如算术、逻辑、内存访问等)

本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0 最后编辑于:2024-12-31
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享