ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Windows进程管理实操:任务管理器与CreateProcess全解析

Windows进程管理实操:任务管理器与CreateProcess全解析

简介:一份面向操作系统课程实验场景的进程管理指导文档,主题为Windows“任务管理器”的进程管理,适合高校计算机专业学生、实验室操作人员及自学操作系统的读者使用。文档从背景知识切入,先介绍任务管理器“应用程序、进程、性能”三个选项卡的功能,再依次演示终止进程、显示其他进程计数器、更改正在运行的程序的优先级等操作,并给出完整实验步骤和实验记录表格,能帮助读者在熟悉Windows任务管理器用法的同时,扎实理解进程、内存、句柄等操作系统核心概念。整个资源包仅含1个doc文档,大小约251KB,打开即可对照练习,无需额外环境配置。这份文档已得到1013人学习下载,曾用于操作系统上机实验参考,可作为课前预习或课堂实验的模板使用。

1. 实验文档里的任务管理器:不只是按几个按钮的事

很多人以为操作系统实验里“任务管理器”那一节就是开个窗口、看看进程再杀掉,交份截图就算完事。等真打开这份实验.doc才知道,里面其实埋了两条线索:一条是任务管理器的界面操作,终止进程、选列、改优先级;另一条是Windows进程的“一生”,从CreateProcess()创建、GetProcessVersion()查询,到ExitProcess()与TerminateProcess()终止。这两条线一条偏界面、一条偏代码,结合起来才算是把进程管理看全。这篇笔记把整个实验拆开,重点讲清楚哪些操作是表面功夫、哪些参数是真正决定进程行为的关键,以及我照着清单编译时踩过的几个坑。适合正在做操作系统实验、或者想搞明白任务管理器底层机制的同学。

2. 任务管理器四张选项卡:终止、选列与优先级的实际操作

背景知识里反复提到“应用程序”“进程”“性能”三个选项卡,实验文档还补了一个“联网”。实操时大多数同学只停留在“任务管理器能看CPU和内存”这个层面,其实这四张表里真正跟进程管理强相关的是“进程”选项卡。我按实验步骤重新走了一遍,把几个关键动作的细节和后果记录如下。

2.1 终止进程的正确姿势:先分清进程类别

实验第一步是打开任务管理器,切到“进程”选项卡,系统会按照用户名把进程分成三类:SYSTEM、NETWORK SERVICE/LOCAL SERVICE、以及Administrator用户进程。清单里记录的40个进程里,系统进程25个、服务进程4个、用户进程12个,加起来还差一个“System Idle Process”,这类空闲进程常被忽略,但它确实占着一个PID。

终止进程时,界面上的“结束进程”按钮会直接调用TerminateProcess(),没有保存数据的机会。实验里要求挑一个进程结束,然后对比前后记录。我当时选了个输入法相关的进程,结果界面闪了一下又自动拉起来——因为这种进程有守护机制。这个现象本身就是知识点:任务管理器“结束进程”杀的是单个进程,但很多程序有父进程或服务在盯着,杀了会被重新拉起。

实操建议是:终止前先看“用户名”列,如果是SYSTEM或者服务类别,除非心中有数,否则别碰。实验文档里也提示了,终止系统服务可能导致网络、打印等组件工作异常。如果只是为了交作业,选一个自己启动的应用程序对应进程,比如Word的WINWORD.EXE,结束之后数据不会丢失(因为Word有备份机制),但严格来说这属于运气好,不是操作正确。

终止进程时有个隐藏行为值得注意:如果进程有子进程,直接结束父进程并不会自动清掉全部子进程树。任务管理器里“结束进程”按钮并没有“结束进程树”选项,右键菜单里那个才是。实验表格要求记录“终止某进程后的操作结果”,我建议把“子进程是否存活”也写进去,这个观察比单纯记“进程消失”更能体现进程独立性的概念。

2.2 显示其他进程计数器:选列不只是加个表头

实验第二部分是“显示其他进程计数器”,在“进程”选项卡点“查看”→“选择列”。默认只有映像名称、用户名、CPU、内存使用,勾选之后可以增加“页面错误”“句柄计数”“USER对象”等。这部分看起来是界面操作,实际上隐藏着一个进程性能观测的核心问题:任务管理器展示的指标分别代表什么。

页面错误不是错误,是进程访问的内存页不在物理内存中、需要从磁盘换入的次数。句柄计数是进程打开的窗口、文件、注册表键等内核对象的数量。USER对象和GDI对象则分别对应窗口管理和图形设备接口资源。如果程序出现窗口泄漏,USER对象数值会持续增长。我一般勾选“句柄数”“USER对象”“工作集大小”这列,比默认列更能看到进程的资源占用趋势。

实验要求记录调整后显示的列名,并把“排序”也练一下。任务管理器默认按CPU排序,但CPU数值经常跳动,我一般改成按“内存使用”排序,这样能快速找出吃内存的进程。排序方式只是点击列标题,再点一次反转顺序,这个动作本身没什么技术含量,但可以用来确认“当前占用最高的进程是谁”这种实际问题。

2.3 优先级调整的影响边界:高优先级不是万能钥匙

实验第三部分要求右键设置优先级,可选“实时”“高”“高于正常”“正常”“低于正常”“低”。改完运行3-6.exe,观察屏幕显示是否有变化。我试过把进程设成“高”,PID信息确实变了,但界面响应速度变化不明显,因为那程序本身就是循环输出,瓶颈不在调度而在于控制台IO。

优先级真正的意义是告诉调度器“相对其他进程,这个进程应获得多少CPU时间”。设置成“实时”会让该进程抢占绝大多数CPU时间,鼠标键盘都可能卡顿。实验文档里提到“在多处理器计算机上可以设置处理器关联”,这个在Windows 2000时代是限制进程只在指定CPU上运行,现在任务管理器的“相关性”就是同一件事。设置相关性会导致总体性能下降,因为进程不能利用其他空闲核——这是很容易被忽略的边界。

实际操作时还会遇到一个权限问题:普通用户只能改自己进程的优先级,改系统进程会提示“拒绝访问”。实验代码里用SetPriorityClass()修改自身优先级,所以能够成功。如果改成别的进程,就需要调试权限或者管理员身份。这一点在避坑章节会展开。

3. 进程的一生:CreateProcess参数拆解与克隆程序实战

实验的第二部分“Windows 2000进程的一生”才是这份文档的硬核内容。文档说“Windows所创建的每个进程都以调用CreateProcess()开始,以ExitProcess()或TerminateProcess()终止”,接着给了清单3-5的完整代码。这个程序会不断克隆自身,每个子进程都带着一个递增的编号,最多创建25个。我照着在Visual C++ 6.0里编译运行,中间出了不少问题,下面把参数和踩坑过程写清楚。

3.1 十个参数逐一说明:哪个能填NULL,哪个不能省

CreateProcess()是Windows进程创建的底层API,原声明有10个参数。实验代码里从第三个参数开始填的都是NULL或FALSE,容易让人以为不重要。实际不是,只是这个场景里需要默认值。

代码片段如下:

BOOL bCreateOK = :: CreateProcess( szFilename, // 应用程序名称:当前EXE的完整路径 szCmdLine, // 命令行:EXE路径 + 空格 + 克隆ID NULL, // 进程安全属性:使用默认安全描述符 NULL, // 线程安全属性:使用默认安全描述符 FALSE, // 不继承调用者的句柄 CREATE_NEW_CONSOLE,// 新进程分配独立控制台窗口 NULL, // 使用父进程的环境变量块 NULL, // 使用父进程的当前目录 &si, // STARTUPINFO结构:指定窗口样式等 &pi // PROCESS_INFORMATION:返回句柄和ID );

参数说明:

  • 第一个参数lpApplicationName可以不传完全路径,系统会按当前目录和PATH环境变量搜索,但传完整路径最稳妥。实验里用GetModuleFileName(NULL, ...)拿到当前进程自己的EXE路径,这是“克隆自身”的关键。
  • 第二个参数lpCommandLine传给新进程的argv。这里把克隆ID拼在路径后面,子进程的main函数里用argc > 1判断并解析。
  • 第五参数bInheritHandle设为FALSE,意味着子进程不会继承父进程已打开的文件句柄、事件等内核对象。这个在复杂程序里很容易埋雷,父进程打开的日志文件句柄如果被继承,子进程退出也可能影响文件关闭。
  • 第六参数CREATE_NEW_CONSOLE专门给控制台程序用的,否则子进程会共用父进程的控制台窗口。实验里任务栏上会出现一堆相同图标的窗口,就是它造成的。
  • 第七、第八参数填NULL表示子进程复制父进程的环境变量和当前目录。
  • 第九参数STARTUPINFO要先把结构体清零并设置cb大小,否则CreateProcess会校验失败。
  • 第十参数PROCESS_INFORMATION是输出缓冲,里面放着子进程的进程句柄、线程句柄和PID。

CreateProcess()返回后,pi.hProcess和pi.hThread指向内核对象。文档里特别强调“调用CloseHandle()关闭这两个句柄很重要”——这是句柄泄漏的高发点。

3.2 编译清单3-5的翻车记录:语法坑与构建顺序

实验文档里的代码文本经历了OCR和复制粘贴,我拿到手直接编译是不通过的。最常见的几个坑:

  • 代码里的引号是中文全角引号“ ”,在C++字符串里必须替换成半角"。
  • sprintf在VC++里可能提示不安全,建议改成wsprintf或_snprintf,不过实验环境是VC6.0,老式::sprintf能用。
  • main函数参数类型没问题,但StartClone里用到TCHAR和MAX_PATH,需要包含<windows.h>和<tchar.h>,且工程设置里要打开UNICODE或MBCS一致,否则字符串类型对不上。

编译步骤也有顺序要求:先Compile生成目标文件,再Build链接成EXE。如果工程没有活动工作区,VC6会问“是否建立default project workspace”,必须点“是”。如果直接点Build,它会先编译再链接,但一般会先弹出保存工作区的对话框。我当时因为没建工程,直接打开.cpp文件点Compile,报了一大堆错误;后来新建了“Win32 Console Application”空工程,再把代码粘贴进去才通过。

这个清单还有个逻辑点:if (nClone < c_nCloneMax)里常量名是c_nCloneMax,代码里写的是C_nCloneMax?文档原文有个细节,创建常量和判断处大小写不一致,会导致编译报“undeclared identifier”。实际复制时要注意统一。这说明实验代码本身可能有笔误,属于正常现象,能跑通才算完成了“排错”训练。

3.3 句柄关闭与CreateProcess的返回值检查

代码里只检查了bCreateOK,如果创建失败,pi里的句柄是无效的,不能盲目CloseHandle。正确做法是创建成功再关闭,失败时调用GetLastError()看错误码。常见错误码有ERROR_FILE_NOT_FOUND(路径不对)、ERROR_ACCESS_DENIED(权限不够)。

实验代码里创建成功后立刻CloseHandle,这是合理的,因为父进程不需要等待子进程,也不需要使用子进程句柄做其他事。但要注意:关闭句柄不等于终止进程,只是释放对内核对象的引用,子进程仍然继续运行。这正好能解释为什么程序运行后,任务栏上一堆窗口依然活跃。

如果真的需要等待子进程结束,应该保留pi.hProcess,并用WaitForSingleObject(pi.hProcess, INFINITE)阻塞等待。实验里用了Sleep(500)来暂停主线程,这属于偷懒写法。实际生产代码里,等待子进程应该用Wait函数,而不是固定Sleep,否则子进程没结束父进程先退出,任务栏窗口还在闪。这个区别实验文档没有明说,但属于进程编程的基本修养。

4. 进程信息查询:GetProcessVersion与版本结构的使用

清单3-6演示了进程自身的PID查询、系统版本获取,以及把当前进程优先级设置为HIGH_PRIORITY_CLASS。代码思路很清晰,但有几个细节需要注意——尤其是程序先判断系统是Windows 2000以上,再提高优先级,这个“版本检查”逻辑在现在的新系统上会走另外一个分支,效果不同。

4.1 从GetCurrentProcessId到OS版本号解析

代码第一段用GetCurrentProcessId()拿到当前进程的PID,然后调用GetProcessVersion(PID)。这个API返回一个32位整数,高16位是主版本号,低16位是次版本号。文档里的代码写作(WORD) dwVerReq > 16),明显是OCR错误,应该是>> 16。我补全后运行正常。

第二段用OSVERSIONINFOEX结构配合GetVersionEx()获取系统版本。这个结构里dwMajorVersion和dwMinorVersion表示主次版本号。比如Windows 2000是5.0,Windows XP是5.1,Windows 7是6.1,Windows 10是10.0。实验记录里写了“OS: 6.1”,说明当时跑在Windows 7上,代码判断dwMajorVersion >= 5通过,于是执行了优先级修改。

代码片段:

DWORD dwIdThis = ::GetCurrentProcessId(); DWORD dwVerReq = ::GetProcessVersion(dwIdThis); WORD wMajorReq = (WORD)(dwVerReq >> 16); WORD wMinorReq = (WORD)(dwVerReq & 0xffff); std::cout << "Process ID: " << dwIdThis << ", requires OS: " << wMajorReq << "." << wMinorReq << std::endl; OSVERSIONINFOEX osvix; ::ZeroMemory(&osvix, sizeof(osvix)); osvix.dwOSVersionInfoSize = sizeof(osvix); ::GetVersionEx(reinterpret_cast<LPOSVERSIONINFO>(&osvix)); std::cout << "Running on OS: " << osvix.dwMajorVersion << "." << osvix.dwMinorVersion << std::endl; if (osvix.dwPlatformId == VER_PLATFORM_WIN32_NT && osvix.dwMajorVersion >= 5) { ::SetPriorityClass(::GetCurrentProcess(), HIGH_PRIORITY_CLASS); std::cout << "Task Manager should now indicate this process is high priority." << std::endl; }

这段代码有个兼容性问题:OSVERSIONINFOEX在Windows 2000之后才引入,在老系统上编译需要定义_WIN32_WINNT=0x0500。如果不定义,部分SDK版本会报结构未定义。另外GetVersionEx在新系统上可能被屏蔽返回值,微软建议用RtlGetVersion或兼容层,但实验教学环境用老VC6.0没问题。代码里的ZeroMemory是memset的宏封装,只用于置零,没有踩坑点。

4.2 SetPriorityClass提高优先级的条件与副作用

SetPriorityClass第二个参数传HIGH_PRIORITY_CLASS,把进程优先级提到“高”。这个操作在任务管理器里看到的效果是“高”,但要注意前提条件:进程必须有PROCESS_SET_INFORMATION访问权限。自己改自己没有权限问题,但改别的进程很可能失败。

副作用方面,把进程提为高优先级,会让它占用更多CPU时间片,系统的其他程序会变卡。实验里这个程序只是输出几行文本就退出了,影响几乎为零。但如果把它改成一个死循环,再把优先级调成“实时”(REALTIME_PRIORITY_CLASS),键盘鼠标都可能无响应。这个现象值得自己试一次,试过就知道为什么操作系统教材强调“实时优先级要谨慎使用”。

4.3 在任务管理器里验证代码运行结果

实验步骤要求运行3-6.exe后,用Ctrl+Alt+Del打开任务管理器,在“应用程序”选项卡右键该任务选“转到进程”,查看对应映像名称。我们的运行结果是对应到VCSPAWN.EXE,这其实是Visual C++的调试辅助进程,不是3-6.exe本身。这给了一个很好的观察点:从Visual Studio环境执行程序时,编译器可能会用rundll32、vcspawn等辅助进程来启动目标程序,导致任务管理器里看到的多了一个“中间进程”。

如果直接在命令行运行编译好的3-6.exe,任务管理器里就会显示3-6.exe。从这也能解释为什么文档里问“在可视化界面下运行与命令行运行有什么不同”——不只是显示方式不同,进程的父进程、路径、甚至映像名都可能不同。这个体会对理解“进程是程序的一次执行”很有帮助。

5. 避坑专区:进程实验里最常见的五个翻车现场

这一章专门记录我反复做这个实验过程中踩过、以及帮同学排过的坑,每一条都是“现象→原因→解决”的结构,照着排查能省不少时间。

5.1 编译清单3-5时一堆未声明标识符

现象:按文档操作,打开源文件直接编译,报错“sprintf' undeclared”“TCHAR' undeclared”,或者大量“missing ; before string constant”。 原因:工程没建立,或者代码粘贴时把全角引号、大小写错误的常量名一起带进来了;另外缺少必要的头文件包含。 解决:先新建Win32 Console Application空工程,再添加.cpp文件。把代码开头的#include <windows.h>和#include <stdio.h>保留完整,引号统一替换成半角。特别注意c_nCloneMax常量在定义处和判断处的大小写必须一致。

5.2 结束进程后,程序又自动回来了

现象:结束某个进程,列表里隔几秒又出现同名进程,或者电脑弹窗提示“某程序已停止工作”后重启。 原因:进程有父进程守护或者系统服务自动拉起。很多输入法、更新程序都采用多进程架构,杀主进程后监护进程会重建它。 解决:先看“用户名”列判断是用户进程还是服务进程。如果是用户进程,尝试右键“结束进程树”来连带终止子进程;如果是服务,应该先去服务管理单元把它停掉,再来清理进程。实验里记录原始数据时要标注“重启前后”,这样报告会更完整。

5.3 改优先级提示“拒绝访问”

现象:右键系统进程或别的用户进程,选“设置优先级”,弹窗拒绝,或者确定后优先级并没有变。 原因:任务管理器按用户权限限制操作。普通用户只能修改自己启动的进程,修改SYSTEM进程需要管理员权限。 解决:用管理员身份运行任务管理器。如果还想更深入,可以右键任务管理器快捷方式,选“以管理员身份运行”。改完优先级后在“进程”选项卡添加“基本优先级”列,确认数值变化。

5.4 运行3-6.exe后任务管理器里找不到对应进程

现象:程序运行时间太短,还没来得及在任务管理器里刷新列表,进程已经退出了。 原因:3-6.exe只做了几行输出和一次优先级设置,运行时间不超过0.5秒。手速跟不上进程生命周期。 解决:在main函数最后加一句Sleep(5000),留出观察时间。或者编译一个带循环版本的测试程序,比如连续输出PID并休眠。实验代码本身用了Sleep(500)在主线程,但那个是在创建子进程实验里,3-6没有。临时修改不算作弊,而是实验手段。

5.5 程序编译通过,但运行时窗口一闪而过

现象:双击EXE或按Ctrl+F5执行,控制台窗口闪一下就没了,看不到输出。 原因:main函数正常返回,进程调用ExitProcess退出,控制台窗口随之关闭。VC6里按Ctrl+F5有时会附加“pause”参数,但命令行直接运行不会。 解决:在main函数结尾加getchar();或Sleep(...),保证窗口停留。临时调试可以,最终代码不要保留这种无意义的等待,或者用实验文档里的Sleep(500)作为折中。

6. 把实验代码改造成一个“进程体检器”:组合技巧

实验做完后,这两份清单代码其实可以合并成一个能在命令行下查出关键进程信息的小工具。我一般这样做:先调用GetCurrentProcessId拿到自己的PID,再用CreateToolhelp32Snapshot遍历全部进程,把每个进程的映像名、PID、优先级列出来,然后用实验里学到的GetProcessVersion判断可执行文件的兼容性。这样一套下来,对任务管理器里看到的每一列都心里有底。

具体实现思路不复杂。创建一个快照,遍历进程列表,比较映像名和PID,输出信息。可以直接复用3-6里的GetProcessVersion逻辑来显示“这个EXE要求的主版本号”。如果发现某个进程要求的OS版本比当前系统高,那大概率是程序跑不起来的原因。这个检测手段在排查老软件时特别有用。

写完之后我习惯手动验证:用任务管理器里“进程”选项卡的PID列和命令行程序输出的PID对照,如果一致,说明快照确实是当下正在跑的进程。再模拟一轮“用任务管理器结束进程”,看看自己写的程序里是否还占着句柄——这能验证CloseHandle是否漏关。实验文档里反复强调句柄释放,不实际遍历一次进程列表,很难有直观感受。

从那以后,我每次遇到程序被杀后残留进程的问题,都会强制走一遍“查看进程树、核对优先级、确认映像名”的流程,而不是直接盲杀。资源里的清单3-5和3-6配合任务管理器界面,足够把进程的创建到终止这条路走通。把这两份实验串起来做一遍,你对进程这个概念的理解会比只刷视频扎实得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进