windows 多任务与进程

多任务,进程与线程的简单说明

多任务的本质就是并行计算,它能够利用至少2处理器相互协调,同时计算同一个任务的不同部分,从而提高求解速度,或者求解单机无法求解的大规模问题。以前的分布式计算正是利用这点,将大规模问题分解为几个互不不相关的问题,将这些计算问题交给局域网中的其他机器计算完成,然后再汇总到某台机器上,显示结果,这样就充分利用局域网中的计算机资源。
相对的,处理完一步接着再处理另外一步,将这样的传统计算模式称为串行计算。
在提高处理器的相关性能主要有两种方式,一种是提高单个处理器处理数据的速度,这个主要表现在CPU主频的调高上,而当前硬件总有一个上限,以后再很难突破,所以现在的CPU主要采用的是调高CPU的核数,这样CPU的每个处理器都处理一定的数据,总体上也能带来性能的提升。
在某些单核CPU上Windows虽然也提供了多任务,但是这个多任务是分时多任务,也就是每个任务只在CPU中执行一个固定的时间片,然后再切换到另一个任务,由于每个任务的时间片很短,所以给人的感觉是在同一时间运行了多个任务。单核CPU由于需要来回的在对应的任务之间切换,需要事先保存当前任务的运行环境,然后通过轮循算法找到下一个运行的任务,再将CPU中寄存器环境改成新任务的环境,新任务运行到达一定时间,又需要重复上述的步骤,所以在单核CPU上使用多任务并不能带来性能的提升,反而会由在任务之间来回切换,浪费宝贵的资源,多任务真正使用场合是多核的CPU上。
windows上多任务的载体是进程和线程,在windows中进程是不执行代码的,它只是一个载体,负责从操作系统内核中分配资源,比如每个进程都有4GB的独立的虚拟地址空间,有各自的内核对象句柄等等。线程是资源分配的最小单元,真正在使用这些资源的是线程。每个程序都至少有一个主线程。线程是可以被执行的最小的调度单位。

进程的亲缘性

进程或者线程只在某些CPU上被执行,而不是由系统随机分配到任何可用的CPU上,这个就是进程的亲缘性。例如某个CPU有8个处理器,可以通过进程的亲缘性设置让该进程的线程只在某两个处理器上运行,这样就不会像之前那样在8个CPU中的任意几个上运行。在windows上一般更倾向于优先考虑将线程安排在之前执行它的那个处理器上运行,因为之前的处理器的高速缓存中可能存有这个线程之前的执行环境,这样就提高的高速缓存的命中率,减少了从内存中取数据的次数能从一定程度上提高性能。需要注意的是,在拥有三级高速缓存的CPU上,这么做意义就不是很大了,因为三级缓存一般作为共享缓存,由所有处理器共享,如果之前在2号处理器上执行某个线程,在三级缓存上留下了它的运行时的数据,那么由于三级缓存是由所有处理器所共享的,这个时候即使将这个线程又分配到5号处理器上,也能访问这个公共存储区,所以再设置线程在固定的处理器上运行就显得有些鸡肋,这也是拥有三级缓存的CPU比二级缓存的CPU占优势的地方。但是如果CPU只具有二级缓存,通过设置亲缘性可以发挥高速缓存的优势,提高效率。
亲缘性可以通过函数SetProcessAffinityMask设置。该函数的原型如下:

BOOL WINAPI SetProcessAffinityMask(
  __in          HANDLE hProcess,
  __in          DWORD_PTR dwProcessAffinityMask
);

第一个参数是设置的进程的句柄,第二个参数是DWORD类型的指针,是一个32位的整数,每一位代表一个处理器的编号,当希望设置进程中的线程在此处理器上运行的话,将该位设置为1,否则为0。
为了设置亲缘性,首先应该了解机器上CPU的情况,根据实际情况来妥善安排,获取CPU的详细信息可以通过函数GetLogicalProcessInformation来完成。该函数的原型如下:

BOOL WINAPI GetLogicalProcessorInformation(
  __out         PSYSTEM_LOGICAL_PROCESSOR_INFORMATION Buffer,
  __in_out      PDWORD ReturnLength
);

第一个参数是一个结构体的指针,第二个参数是需要缓冲区的大小,也就是说这个函数我们可以采用两次调用的方式来合理安排缓冲区的大小。结构体SYSTEM_LOGICAL_PROCESSOR_INFORMATION的定义如下:

 typedef struct _SYSTEM_LOGICAL_PROCESSOR_INFORMATION 
 {  
     ULONG_PTR ProcessorMask;
     LOGICAL_PROCESSOR_RELATIONSHIP Relationship;
     union {
         struct {
             BYTE Flags;
            } ProcessorCore;    
         struct {
             DWORD NodeNumber;
             }NumaNode;    
        CACHE_DESCRIPTOR Cache;
        ULONGLONG Reserved[2];
    };
} SYSTEM_LOGICAL_PROCESSOR_INFORMATION,  *PSYSTEM_LOGICAL_PROCESSOR_INFORMATION;

ProcessorMask是一个位掩码,每个位代表一个逻辑处理器,第二个参数是一个标志,表示该使用第三个共用体中的哪一个结构体,这三个分别表示核心处理器,NUMA节点,以及高速缓存的信息。
下面是使用的一个例子代码:

#include <windows.h>
#include <tchar.h>
#include <stdio.h>
#include <locale.h>

DWORD CountBits(ULONG_PTR uMask);
typedef BOOL (WINAPI *CPUINFO)(PSYSTEM_LOGICAL_PROCESSOR_INFORMATION Buffer, PDWORD ReturnLength);

int _tmain(int argc, TCHAR *argv[])
{
    _tsetlocale(LC_ALL, _T("chs"));
    CPUINFO pGetCpuInfo = (CPUINFO)GetProcAddress(GetModuleHandle(_T("kernel32")), "GetLogicalProcessorInformation");
    if (NULL == pGetCpuInfo)
    {
        _tprintf(_T("系统不支持该函数,程序结束\n"));
        return 0;
    }

    PSYSTEM_LOGICAL_PROCESSOR_INFORMATION plpt = NULL;
    DWORD dwLength = 0;
    if (!pGetCpuInfo(plpt, &dwLength))
    {
        if (ERROR_INSUFFICIENT_BUFFER == GetLastError())
        {
            plpt = (PSYSTEM_LOGICAL_PROCESSOR_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwLength);
        }else
        {
            _tprintf(_T("函数调用失败\n"));
            return 0;
        }
    }

    if (!pGetCpuInfo(plpt, &dwLength))
    {
        _tprintf(_T("函数调用失败\n"));
        return 0;
    }

    DWORD dwSize = dwLength / sizeof(SYSTEM_LOGICAL_PROCESSOR_INFORMATION);
    PSYSTEM_LOGICAL_PROCESSOR_INFORMATION pOffsite = plpt;
    DWORD dwCacheCnt = 0;
    DWORD dwCoreCnt = 0;
    DWORD dwPackageCnt = 0;
    DWORD dwNodeCnt = 0;

    for (int i = 0; i < dwSize; i++)
    {
        switch (pOffsite->Relationship)
        {
        case RelationCache:
            dwCacheCnt++;
            break;
        case RelationNumaNode:
            dwNodeCnt++;
            break;
        case RelationProcessorCore:
            {
                if (pOffsite->ProcessorCore.Flags)
                {
                    dwCoreCnt++;
                }else
                {
                    dwCoreCnt += CountBits(pOffsite->ProcessorMask);
                }
            }
            break;
        case RelationProcessorPackage:
            dwPackageCnt++;
            break;
        default:
            break;
        }   

        pOffsite++;
    }

    _tprintf(_T("处理器个数:%d\n"), dwPackageCnt);
    _tprintf(_T("处理器核数:%d\n"), dwCoreCnt);
    _tprintf(_T("NUMA个数:%d\n"), dwNodeCnt);
    _tprintf(_T("高速缓存的个数:%d\n"), dwCacheCnt);
    return 0;
}

DWORD CountBits(ULONG_PTR uMask)
{
    DWORD dwTest = 1;
    DWORD LSHIFT = sizeof(ULONG_PTR) * 8 -1;
    dwTest = dwTest << LSHIFT;
    DWORD dwCnt = 0;
    for (int i = 0; i <= LSHIFT; i++)
    {
        dwCnt += ((uMask & dwTest) ? 1 : 0);
        dwTest /= 2;
    }

    return dwCnt;
}

这个程序首先采用两次调用的方式分配一个合适的缓冲区,用来接收函数的返回,然后分别统计CPU数目,物理处理器数目以及逻辑处理器数量。我们根据MSDN上面得到的信息,首先判断缓冲区中有多少个结构体,也就是由多少条处理器的信息,然后根据第二个成员的值,来判断当前存储的是哪种信息。并将对应的值加1,当计算逻辑处理器的数目时需要考虑超线程的问题,所谓超线程就是intel提供的一个新的技术,可以将一个处理器虚拟成多个处理器来使用,已达到多核处理器的效果,如果它支持超线程,那么久不能简单的根据是否为核心处理器而加1,这个时候需要采用计算位掩码的方式来统计逻辑存储器,根据MSDN上说的当flag为1时表示支持超线程。

windows下的进程

windows中进程是已装入内存中,准备或者已经在执行的程序,磁盘上的exe文件虽说可以执行,但是它只是一个文件,并不是进程,一旦它被系统加载到内存中,系统为它分配了资源,那么它就是一个进程。进程由两个部分组成,一个是系统内核用来管理进程的内核对象,一个是它所占的地址空间。
windows下的进程主要分为3大类:控制台,窗口应用,服务程序。写过控制台与窗口程序的人都知道,控制台的主函数是main,而窗口应用的主函数是WinMain,那么是否可以根据这个来判断程序属于那种呢,很遗憾,windows并不是根据这个来区分的。在VS编译器上可以通过设置将Win32 控制台程序的主函数指定为WinMain,或者将窗口程序的主函数指定为main,设置方法:属性–>连接器–>系统–>子系统,将这项设置为/SUBSYSTEM:CONSOLE,那么它的主函数为main,设置为/SUBSYSTEM:WINDOWS那么它的主函数为WinMain,甚至我们可以自己设置主函数。
我们知道在C/C++语言中main程序是从main函数开始的,但是这个函数只是语法上的开始,并不是真正意义上的入口,在VC++中,系统会首先调用mainCRTStartup,在这个函数中调用main或者WinMain, 这个函数主要负责对C/C++运行环境的初始化,比如堆环境或者C/C++库函数环境的初始化。如果需要自定义自己的入口,那么这些环境将得不到初始化,也就意味着我们不能使用C/C++库函数。只能使用VC++提供的API,这么做也有一定的好处,毕竟这些库函数都是在很早之前产生的,到现在来看有很多问题,有许多有严重的安全隐患,使用API可以避免这些问题。下面是一个简单的例子:

#include <Windows.h>
#include <tchar.h>
#include <strsafe.h>

#define PRINTF(...) \
    {\
        TCHAR szBuf[4096] = _T("");\
        StringCchPrintf(szBuf, sizeof(szBuf), __VA_ARGS__);\
        size_t nStrLen = 0;\
        StringCchLength(szBuf, STRSAFE_MAX_CCH, &nStrLen);\
        WriteConsole(GetStdHandle(STD_OUTPUT_HANDLE), szBuf, nStrLen, NULL, NULL);\
    }

int LHMain()
{
    PRINTF(_T("this is a %s"), _T("test hello world\n"));
    return 0;
}

这个例子中,入口函数是LHMain我们完全使用API的方式来编写程序,如果想要做到自定义入口,那么需要进行这样的设置:属性–>高级–>入口点,在入口点中输出我们希望作为入口点的函数名称即可。

入口参数各个参数的含义

现在这部分主要说明main函数以及WinMain函数。

int main()

这是main函数的一种原型,这种原型不带入任何参数

int main(int argc, char *argv[])

这种原型主要接受命令行输入的参数,参数以空格隔开,第一个参数表示输入命令行参数的总数,第二个是一个字符串指针数组,每个字符串指针成员代表的是具体输入的命令以及参数,这些信息包括我们输入的程序的名称,比如我们输入test.exe -s -a -t 来启动程序,那么argc = 4 ,argv[0] = “test.exe” argv[1] = “-s” argv[2] = “-a” argv[3] = “-t”

int main(int argc, char *argv[], char *envs[])

这个原型中第一个和第二个参数的函数与上述的带有两个参数的main函数相同,多了一个表示环境变量的指针数组,它会将环境变量以”变量名=值“的形式来组织字符串。

int WINAPI WinMain(HANDLE hInstance, HANDLE hPrevInstance, LPSTR lpszCmdLine, int nCmdShow);

函数中的WINAPI表示函数的调用约定为__stdcall的调用方式,函数的调用方式主要与参数压栈方式以及环境回收的方式有关。在这就不再说明这部分的内容,有兴趣的可以看本人另外一篇博客专门讲解了这部分的内容,点击这里
hInstance:进程的实例句柄,该值是exe文件映射到虚拟地址控件的基址
hPrevInstance:上一个进程的实例句柄,为了防止进程越界访问,这个是16位下的产物,在32位下,这个没有作用。
lpszCmdLine:启动程序时输入的命令行参数
nCmdShow:表示程序的显示方式。

进程的环境变量与工作路径

进程的环境变量可以通过main函数的第三个参数传入,也可以在程序中利用函数GetEnvrionmentStrings和GetEnvrionVariable获取,下面是获取进程环境变量的简答例子:

    setlocale(CP_ACP, "chs");
    TCHAR **ppArgs = NULL;
    int nArgCnt = 0;
    ppArgs = CommandLineToArgvW(GetCommandLine(), &nArgCnt);
    for (int i = 0; i < nArgCnt; i++)
    {
        _tprintf(_T("%d %s\n"), i, ppArgs[i]);
    }

    HeapFree(GetProcessHeap(), 0, ppArgs);

函数的第一个参数是开启这个进程需要的完整的命令行字符串,这个字符串使用函数GetCommandLine来获取,第二个参数是一个接受环境变量的字符串指针数组。函数返回数组中元素个数。
一般情况下不推荐使用环境变量的方式来保存程序所需的数据,一般采用文件或者注册表的方式,但是最好的办法是采用xml文件的方式来村粗。
至于进程的工作目录可以通过函数GetCurrentDirectory方法获取。

进程创建

在windows下进程创建采用API函数CreateProcess,该函数的原型如下:

BOOL CreateProcess( 
  LPCWSTR pszImageName, 
  LPCWSTR pszCmdLine, 
  LPSECURITY_ATTRIBUTES psaProcess, 
  LPSECURITY_ATTRIBUTES psaThread, 
  BOOL fInheritHandles, 
  DWORD fdwCreate, 
  LPVOID pvEnvironment, 
  LPWSTR pszCurDir, 
  LPSTARTUPINFOW psiStartInfo, 
  LPPROCESS_INFORMATION pProcInfo
); 

该函数参数较多,下面对这几项做重点说明,其余的请自行查看MSDN。
1. pszImageName:表示进程对应的exe文件所在的完整路径或者相对路径
2. pszCmdLine:启动进程传入的命令行参数,这是一个字符串类型,需要注意的是,这个命令行参数可以带程序所在的完整路径,这样就可以将第一个参数设置为NULL。
3. 参数中的安全描述符表示的是创建的子进程的安全描述符,与当前进程的安全描述符无关。
4. fInheritHandles表示,可否被继承,这个继承关系是指父进程中的信息能否被子进程所继承
5. psiStartInfo规定了新进程的相关启动信息,主要有这样几个重要的值:
对于窗口程序:
LPTSTR lpTitle; DWORD dwX; DWORD dwY; DWORD dwXSize; DWORD dwYSize;这些值规定了窗口的标题和所在的屏幕位置与长高的相关信息,对于控制台程序,主要关注: HANDLE hStdInput; HANDLE hStdOutput; HANDLE hStdError;标准输入、输出、以及标准错误
下面是一个创建控制台与创建窗口的简单例子:

    STARTUPINFO si = {0};
    si.dwXSize = 400;
    si.dwYSize = 300;
    si.dwX = 10;
    si.dwY = 10;

    PROCESS_INFORMATION pi = {0};
    SECURITY_ATTRIBUTES sa = {sizeof(SECURITY_ATTRIBUTES)};
    //创建一个窗口应用的进程,其中szExePath表示进程所在exe文件的路径,而szAppDirectory表示exe所在的目录
    CreateProcess(szExePath, szExePath, &sa, &sa, FALSE, 0, NULL, szAppDirectory, &si, &pi);
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    //启动控制台窗口,与父进程公用输入输出环境
    ZeroMemory(szExePath, sizeof(TCHAR) * (MAX_PATH + 1));
    StringCchPrintf(szExePath, MAX_PATH, _T("%s%s"), szAppDirectory, _T("SubConsole.exe"));
    ZeroMemory(&si, sizeof(STARTUPINFO));
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
    CreateProcess(szExePath, szExePath, &sa, &sa, FALSE, 0, NULL, szAppDirectory, &si, &pi);
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    //启动控制台,在新的控制台上做输入输出
    ZeroMemory(&si, sizeof(STARTUPINFO));
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
    CreateProcess(szExePath, szExePath, &sa, &sa, FALSE, CREATE_NEW_CONSOLE, NULL, szAppDirectory, &si, &pi);
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

上述程序中,对于窗口程序,在创建时没有给出特别的创建标志,窗口本身就是一个个独立的,并且我们通过指定si的部分成员指定了窗口的显示位置,而对于控制台,如果在创建时不特别指定创建的标志,那么它将与父进程共享一个输入输出控制台。为了区分子进程和父进程的输入输出,一般通过标志CREATE_NEW_CONSOLE为新进程新建一个另外的控制台。

进程输入输出重定向

输入输出重定向的实现可以通过函数CreateProcess在参数psiStartInfo中的HANDLE hStdInput; HANDLE hStdOutput; HANDLE hStdError中指定,但是需要注意的是,在父进程中如果采用了Create之类的函数创建了输入输出对象的句柄时一定要指定他们可以被子进程所继承。下面是一个重定向的例子:

    //启动控制台,做输入输出重定向到文件中
    TCHAR szFilePath[MAX_PATH + 1] = _T("");
    //指定文件对象可以被子进程所继承,以便子进程可以使用这个内核对象句柄
    sa.bInheritHandle = TRUE;
    ZeroMemory(&si, sizeof(STARTUPINFO));
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
    StringCchPrintf(szFilePath, MAX_PATH, _T("%s%s"), szAppDirectory, _T("input.txt"));
    HANDLE hInputFile = CreateFile(szFilePath, GENERIC_READ | GENERIC_WRITE, 0, &sa, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    ZeroMemory(szFilePath, sizeof(szFilePath));
    StringCchPrintf(szFilePath, MAX_PATH, _T("%s%s"), szAppDirectory, _T("output.txt"));
    HANDLE hOutputFile = CreateFile(szFilePath, GENERIC_READ | GENERIC_WRITE, 0, &sa, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    si.hStdInput = hInputFile;
    si.hStdOutput = hOutputFile;
    si.dwFlags = STARTF_USESTDHANDLES;
    CreateProcess(szExePath, szExePath,NULL, NULL, TRUE, DETACHED_PROCESS, NULL, szAppDirectory, &si, &pi);
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(hOutputFile);
    CloseHandle(hInputFile);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    //启动ping命令,并将输出重定向到管道中
    StringCchPrintf(szExePath, MAX_PATH, _T("ping 127.0.0.1"));
    DWORD dwLen = 0;
    BYTE byte[1024] = {0};
    HANDLE hReadP = NULL;
    HANDLE hWriteP = NULL;
    sa.bInheritHandle = TRUE;
    CreatePipe(&hReadP, &hWriteP, &sa, 1024);
    si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
    si.wShowWindow = SW_HIDE;
    si.hStdOutput = hWriteP;
    CreateProcess(NULL, szExePath, NULL, NULL, TRUE, DETACHED_PROCESS, NULL, szAppDirectory, &si, &pi);
    //关闭管道的写端口,不然读端口会被阻塞
    CloseHandle(hWriteP);
    dwLen = 1000;
    DWORD dwRead = 0;
    while (ReadFile(hReadP, byte, dwLen, &dwRead, NULL))
    {
        if ( 0 == dwRead )
        {
            break;
        }
        //写入管道的数据为ANSI字符
        printf("%s\n", (char*)byte);
        ZeroMemory(byte, sizeof(byte));
    }

进程的退出

进程在遇到如下情况中的任意一种时会退出:
1. 进程中任意一个线程调用函数ExitProcess
2. 进程的主线程结束
3. 进程中最后一个线程结束
4. 调用TerminateProcess
在这针对第2、3中情况作特别的说明:这两种情况看似矛盾不是吗,当主线程结束时进程就已经结束了,这个时候还会等到最后一个线程吗。其实真实的情况是主线程结束,进程结束这个限制是VC++上的,之前在自定义入口的时候说过,main函数只是语法上的,并不是实际的入口,在调用main之前会调用mainCRTStartup,这个函数会负责调用main函数,当main函数调用结束后,这个函数会隐式的调用ExitProcess结束进程,所以只有当我们自定了程序入口才会看到3所示的现象,下面的例子说明了这点:

DWORD WINAPI ThreadProc(LPVOID lpParam);
#define PRINT(s) \
{\
    size_t sttLen = 0;\
    StringCchLengthA(s, 1024, &sttLen);\
    WriteConsoleA(GetStdHandle(STD_OUTPUT_HANDLE), s, sttLen, NULL, NULL);\
}

int LHMain(int argc, char *argv[])
{
    CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
    return 0;
}

DWORD WINAPI ThreadProc(LPVOID lpParam)
{
    PRINT("main thread is ending,this is child thread\r\n");
    return 0;
}

这种程序,如果我们采用系统中规定的main函数的话,是看不到线程中输出的信息的,因为主线程先结束的话,整个进程就结束了,线程还来不及输出,就被终止了。但是我们采用自定义入口的方式,屏蔽了这个特性,所以它会等到所有线程执行完成后才会结束,这个时候就会看到这句话输出了。
进程在终止时会发生以下事件:
1. 关闭进程打开的对象句柄,但是对象本身不一定会关闭,这是因为每个对象都有一个计数器,每当有一个线程在使用这个对象时计数器会加1,而释放它的句柄时会减一,只有当计数器为0时才会销毁这个对象。对象是可以跨进程访问的,而且所有相同的对象在内存中只有一份,所以需要一个计数器以便在没有被任何进程访问的时候系统删除它。
2. 进程对象的状态设为有信号,以便所有等待该进程对象信号的函数(像函数WaitForSingleObject)能够正常返回。
3. 进程的终止状态从STILL_ACTIVE变成进程的退出码,可以通过这个特性判断某个进程是否在运行,具体的方式是通过函数GetExitProcess获取进程的终止码,如果函数返回STILL_ACTIVE,则说明进程仍在运行。

posted @ 2017-10-24 20:55  masimaro  阅读(365)  评论(0编辑  收藏  举报