轻松理解进程和线程






4.89/5 (19投票s)
在本文中,我们将了解 Windows 中的进程和线程
在本系列文章中,我想带您了解在 Windows 和 .NET 中关于线程所需的一切知识。要更好地理解多线程,需要理解一些基本概念和术语。
- 进程和线程简介。
- .NET 中的多线程。
- 线程池。
- 操作系统多任务处理。
- 操作系统调度。
在本系列的第一部分,我将尝试介绍进程。
1- 进程
在我开始编程时,我总是在想操作系统如何管理应用程序。我记得在编程的早期,我曾尝试用 C++ 编写一个应用程序,该应用程序可以定位内存中玩家生命值(反恐精英 1.6)的活动变量并增加它 :D(我实际上是反恐精英的职业玩家)。
正如您所知,我从未成功过,但那个错误促使我深入研究内存管理。这个问题仍然困扰着我:为什么这个东西不起作用?我将解释为什么它不起作用。
进程是由操作系统创建的一个对象,它的作用是为应用程序正常运行提供所有必需的资源。
通过 Microsoft 文档的 CreateProcess 函数(也有其他函数),会为每个要运行的应用程序创建一个新进程。该函数返回一个布尔值,表示是否成功。
进程何时终止?
当以下情况发生时,进程终止:
- 进程内的所有线程都终止。
- 其任何一个线程调用 ExitProcess 函数。
- 从进程外部调用 TerminateProcess 函数。
那么,进程包含或提供什么?这是一个简短的列表:
- 私有虚拟地址空间。
- 线程。
- 私有进程句柄表。
- 访问令牌。(确定代码在该进程中执行的安全上下文)
在探讨完进程的相关主题后,我们将直接进入代码并进行研究。
1- 虚拟地址空间
每个进程会看到一个扁平的线性内存,然后该内存被映射到物理内存。这实际上是物理内存之上的一个抽象层(这就是我的应用程序不起作用的原因 :D),因为进程通过其指针访问内存中的数据,而不管数据实际位于何处。它可能是 RAM 或称为页面文件(page file)的东西。您可以在 MSDN 上找到有关页面文件的更多信息,请点击 此处。内存管理由 Windows 内存管理器完成。
(有关 Windows 内存管理功能的更多信息,请点击 此处)
您可以在任务管理器中查看每个进程正在使用的进程和虚拟内存。
在 32 位 Windows 上,此虚拟内存的大小为 2 GB,在 64 位 Windows 上为 8 TB。
2- 线程
正如您可能知道的,线程位于进程内,是执行代码的实体。简单来说,线程是一种代码流,一端是代码,另一端是机器的处理器。
一个进程可能包含上百个线程,这可能有多种原因,我们将在后续部分讨论。每个线程将在不同的处理器或至少是处理器的不同核心上运行。
让我们回到 Windows 任务管理器,看看当前正在运行哪些线程。
一个非常粗略的计算表明,我的机器上当前运行着 152 个线程。我敢肯定我的计算机没有 152 个核心,那么这是怎么回事?!
嗯,当有足够的处理器时,每个线程都会在自己的核心上运行;但当核心不足时,Windows 线程调度程序会让每个线程运行一小段时间,然后在保存其状态后,另一个线程将取代它的位置在处理器上运行。
对于 Windows 来说,这个时间大约是 20 毫秒,但不同操作系统之间会有差异。这个过程称为 上下文切换。
上下文切换通常计算成本很高,而操作系统设计的很大一部分就是优化上下文切换的使用。从一个进程切换到另一个进程需要一定的时间来进行管理——保存和加载寄存器和内存映射,更新各种表和列表等。
那么,线程包含或实际拥有什么?
- 线程上下文
- 安全令牌
- 调度状态
1- 线程上下文
线程上下文 包括线程的机器寄存器集、内核堆栈、线程环境块以及线程进程地址空间中的用户堆栈。线程还可以拥有自己的安全上下文,用于临时模拟客户端。
正如我们所见,线程上下文由一组机器寄存器、用户堆栈和内核堆栈组成。
- 机器寄存器集:确定线程当前正在使用的处理器寄存器。
- 内核模式堆栈和用户模式堆栈
发生上下文切换时,有关线程的信息将被保存在其堆栈中。为了将内核代码与故障隔离,在用户模式下执行时,即使内核内存被映射,也无法访问。最终,用简单的语言来说,这两个堆栈之间有什么区别?
基本上,用户模式堆栈用于包含局部变量和方法参数传递,以及当前方法返回后的下一个执行目标。另一方面,当应用程序代码(用户模式)调用内核代码时,会使用内核模式堆栈。此时,Windows 会将参数从用户模式堆栈复制到内核模式堆栈,以便对其进行验证并确保自应用程序无法访问内核模式堆栈的代码以来,它们不会再被更改。
线程环境块
简称 TEB,这是一个在用户模式下初始化的内存块(4 KB),其中包含有关线程的一些信息。
这里有一个非常酷的调试器命令 !teb,它显示特定 TEB 包含的信息。其中一些最重要的是:
- 线程局部存储
- 异常列表
- 异常代码
- ... .
2- 线程安全令牌
此项将在本文的后续部分进行解释,因为它与进程访问令牌相同。
3- 调度状态
每个线程可以有以下状态之一:
状态 | |
---|---|
已中止 (Aborted) | 线程状态包括 AbortRequested,并且线程现在已终止,但其状态尚未更改为 Stopped。 |
请求中止 (AbortRequested) | 已在线程上调用 Thread.Abort 方法,但线程尚未收到将尝试终止它的挂起的 System.Threading.ThreadAbortException。 |
背景 | 线程作为后台线程执行,而不是前台线程。此状态通过设置 Thread.IsBackground 属性来控制。 |
运行 | 线程已启动,未被阻塞,并且没有挂起的 ThreadAbortException。 |
Stopped | 线程已停止。 |
请求停止 (StopRequested) | 正在请求线程停止。这仅供内部使用。 |
Suspended | 线程已挂起。 |
请求挂起 (SuspendRequested) | 正在请求线程挂起。 |
未启动 (Unstarted) | 尚未在线程上调用 Thread.Start 方法。 |
等待/睡眠/连接 (WaitSleepJoin) | 线程被阻塞。这可能是由于调用 Thread.Sleep 或 Thread.Join,例如通过调用 Monitor.Enter 或 Monitor.Wait 请求锁,或等待线程同步对象(如 ManualResetEvent)。 |
那么,我们的机器上的线程是如何运行的呢?
线程优先级:线程根据其优先级属性进行调度,该属性的值可以从 0 到 31。
线程的基础优先级级别和进程优先级类
进程优先级类包含一系列线程优先级级别。此值与每个线程的优先级一起,将形成每个线程的基础优先级,最终决定线程的执行优先级。
这里我想展示这两种组合的示例:
进程优先级类 | 线程优先级级别 | 基础优先级 |
---|---|---|
IDLE_PRIORITY_CLASS | THREAD_PRIORITY_IDLE | 1 |
THREAD_PRIORITY_LOWEST | 2 | |
THREAD_PRIORITY_BELOW_NORMAL | 3 | |
THREAD_PRIORITY_NORMAL | 4 | |
THREAD_PRIORITY_ABOVE_NORMAL | 5 | |
THREAD_PRIORITY_HIGHEST | 6 | |
THREAD_PRIORITY_TIME_CRITICAL | 15 |
这里还有一件事我想讨论,那就是线程消息队列。
在 计算机科学 中,消息队列是用于进程间通信 (IPC) 或同一进程内线程间通信的软件工程组件。
所以,首先,让我们看看什么是消息队列。
默认情况下,当创建一个线程时,它被假定为一个工作线程;但是,如果一个线程调用了任何 GDI32.dll 函数,Windows 就会为该特定线程创建一个消息队列。
由于基于 Windows 的应用程序是事件驱动的,它们获取用户输入的方式与 MS-DOS 应用程序不同,MS-DOS 应用程序通过调用 C 运行时库函数来获取输入。相反,它们依赖系统将输入传递给它们。
Windows 操作系统中的每个窗口都与一个窗口过程相关联,该过程是一个函数,用于处理发送或发布到所有窗口的所有消息并将控制权交给系统。
一旦窗口停止响应系统发布的的消息,它将被视为无响应,这对我们开发者来说很糟糕,因为这里就出现了糟糕的部分,一旦窗口被视为无响应,系统将用一个具有相同 x 和 y 位置的“鬼魂窗口”替换它,并允许用户实际关闭它。
我们从这段话中得到的信息是,如果一个线程被阻塞并且 UI 位于该线程上,这意味着我们的应用程序现在无响应,并且有被用户终止的风险,在最糟糕的情况下,会从任务管理器中收到“结束任务”。
这种情况是应用程序中使用多线程最重要的用例之一。
3- 进程句柄表
它保存了进程(实际在进程中运行的代码)打开的所有内核对象。对于不知道句柄的人来说:
对象是表示系统资源的数据结构,例如文件、线程或图形图像。应用程序无法直接访问对象数据或对象所代表的系统资源。相反,应用程序必须获取对象句柄,然后可以使用该句柄检查或修改系统资源。
每个句柄在内部维护的表中都有一个条目。这些条目包含资源的地址以及标识资源类型的方法。
对象-句柄机制有两个巨大的好处:
首先,它确保在 Microsoft 更新系统功能时,原始对象接口得到维护,因为较新版本正在发布。
其次,它提供了一个保护层,因为它有自己的 ACL(访问控制列表)。ACL 指定了特定进程可以在对象上执行的操作。每次创建特定对象的句柄时,系统都会检查 ACL。
创建事件对象后,应用程序可以使用事件句柄来设置事件或等待事件。此句柄在应用程序关闭句柄或终止之前一直有效。
进程句柄表仅用于内核对象,不用于用户对象或 GDI 对象。
内核对象用于执行系统操作,如内存管理、进程执行和 IPC(进程间通信)。
任何进程(即使是由另一个进程创建的)都可以创建现有内核对象的句柄,前提是该特定进程知道对象的名称并且对该对象具有安全访问权限。
哦,我提到了安全访问。每个对象都有自己的访问权限集。例如,文件句柄可能具有读取或写入访问权限,甚至两者兼有。
内核对象 | 创建函数 | 销毁函数 |
---|---|---|
访问令牌 | CreateRestrictedToken, DuplicateToken, DuplicateTokenEx,OpenProcessToken, OpenThreadToken | CloseHandle |
更改通知 | FindFirstChangeNotification | FindCloseChangeNotification |
通信设备 | CreateFile | CloseHandle |
控制台输入 | CreateFile | CloseHandle |
控制台屏幕缓冲区 | CreateFile | CloseHandle |
桌面 | GetThreadDesktop | 应用程序不能删除此对象。 |
事件 | CreateEvent, CreateEventEx, OpenEvent | CloseHandle |
事件日志 | OpenEventLog, RegisterEventSource, OpenBackupEventLog | CloseEventLog |
文件 | CreateFile | CloseHandle, DeleteFile |
3- 访问令牌
访问令牌旨在描述特定进程或线程的安全上下文,因为线程经常使用进程的安全令牌。
用户登录系统后,将生成此令牌,然后之后执行的所有进程都会获得此访问令牌的副本。
那么它何时起作用?
当线程尝试与 可安全对象 交互时,系统会使用此令牌进行标识。
访问令牌包含的信息如下,由 Microsoft 记录:
- 用户账户的 安全标识符 (SID)
- 用户所属组的 SID
- 一个 登录 SID,用于标识当前 登录会话
- 用户或用户组拥有的 特权 列表
- 所有者 SID
- 主要组的 SID
- 当用户创建可安全对象而未指定 安全描述符 时,系统使用的默认 DACL
- 访问令牌的来源
- 令牌是 主令牌 还是 模拟令牌
- 可选的 限制性 SID 列表
- 当前模拟级别
- 其他统计信息
访问令牌的类型
有两种访问令牌:一种是主令牌 (Primary Token),另一种是模拟令牌 (Impersonation token)。
主令牌默认分配给每个进程,并包含当前登录用户的 P信息。模拟令牌用于当线程需要除当前登录用户以外的用户的身份时。这在服务器应用程序中很有用,当它们需要客户端身份来执行特定任务时。
可以通过调用 SetThreadToken Windows 函数来设置模拟令牌。
一个线程可以同时拥有两种访问令牌吗?当然可以。
可以使用以下函数来操作访问令牌:
函数 | 描述 |
---|---|
AdjustTokenGroups | 更改访问令牌中的组信息。 |
AdjustTokenPrivileges | 启用或禁用访问令牌中的特权。它不授予新特权或撤销现有特权。 |
CheckTokenMembership | 确定指定的 SID 是否在指定的访问令牌中启用。 |
CreateRestrictedToken | 创建一个新的令牌,它是现有令牌的受限制版本。受限制的令牌可以拥有禁用的 SID、删除的特权和受限制的 SID 列表。 |
DuplicateToken | 创建一个新的模拟令牌,该令牌复制现有令牌。 |
DuplicateTokenEx | 创建一个新的主令牌或模拟令牌,该令牌复制现有令牌。 |
GetTokenInformation | 检索有关令牌的信息。 |
IsTokenRestricted | 确定令牌是否具有限制性 SID 列表。 |
OpenProcessToken | 检索进程主访问令牌的句柄。 |
OpenThreadToken | 检索线程模拟访问令牌的句柄。 |
SetThreadToken | 为线程分配或删除模拟令牌。 |
SetTokenInformation | 更改令牌的所有者、主要组或默认 DACL。 |
好的,第一部分就到这里了。在下一篇文章中,我们将开始讨论 .NET 框架中的多线程。