现在的位置: 首页 > 综合 > 正文

【Windows】线程漫谈(一)

2018年07月10日 ⁄ 综合 ⁄ 共 7601字 ⁄ 字号 评论关闭

【Windows】线程漫谈——线程基础

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

进程与线程

理解线程是至关重要的,每个进程至少有一个线程,进程是线程的容器,线程才是真正的执行体,线程必然在某个进程的上下文中运行。进程拥有惰性,如果进程中所有的线程都已结束,那么进程也就没有存在的必要了。

一个进程由如下两部分组成:1、一个进程地址空间;2、一个进程内核对象

一个线程由如下两部分组成:1、一个线程栈;2、一个线程内核对象

线程的开销要比进程少很多,所以在解决编程问题的时候尽量考虑在当前进程中创建线程而不是创建新的进程。然而,线程的切换需要消耗一定数量的CPU资源,因此,也不是说可以毫无顾忌的使用线程来处理问题。

 

线程生命周期

线程的创建

系统创建一个线程内核对象;

系统在当前进程的中预订一块线程栈空间,并调拨一些物理内存;

线程终止运行

释放线程所拥有的所有用户对象(不太理解)

线程退出代码从STILL_ACTIVE变成真正的退出代码

线程内核对象变为触发状态

如果线程是进程中最后一个活动线程,进程将终止

线程内核对象的使用计数减1

 

线程的创建和终止方法

无论你使用什么编程语言,什么类库,在Windows平台下最终创建线程都应该有下面的API

1
2
3
4
5
6
7
8
HANDLE
WINAPI CreateThread(
  __in_opt   LPSECURITY_ATTRIBUTES lpThreadAttributes,//安全描述符,一般传入NULL,但其中的bInheritHandle标志位说明线程内核对象是否允许子进程继承
  __in      
SIZE_T dwStackSize,//线程栈初始化大小,该值可以传入0,系统会从/STACK链接选项和此值两个中选一个较大的
  __in       LPTHREAD_START_ROUTINE lpStartAddress,//线程执行的初始函数的地址
  __in_opt  
LPVOID lpParameter,//传入初始函数的参数
  __in      
DWORD dwCreationFlags,//指定线程是否能被立即调度(即是否立即执行),如果为CREATE_SUSPENDED,系统会在初始化后暂停线程的运行
  __out_opt 
LPDWORD lpThreadId//线程ID,可以传入NULL
);

需要说明的是线程初始函数总是拥有如下函数签名:

1
2
3
DWORD
WINAPI ThreadProc(
  __in 
LPVOID lpParameter
);

该函数返回创建好的线程内核对象的句柄。除非该句柄值将用作他用,否则应该立即调用CloseHandle来关闭句柄,当然关闭句柄不会终止线程的运行,但可以保证线程在退出后即使的释放线程内核对象。

终止线程的方式有线程初始函数返回、线程自己调用ExitThread终止自己、外部线程调用TerminateThread、包含线程的进程终止运行。其中除了第一种方法,其他都是不推荐的方式,因为线程不正常退出不能保证资源的正确释放

使用ExitThread还能保证系统销毁线程的堆栈,但TerminateThread将无法做到,直到进程终止;

 

线程的初始化内幕

image

CreateThread导致系统创建一个线程内核对象,该对象的初始引用计数为2。线程正常退出将递减一次,关闭线程句柄将递减一次,引用计数为0时,操作系统会释放改内核对象。暂停计数设置为1,退出代码设置为STILL_ACTIVE,对象被设置为未触发状态。

系统从进程地址空间中分配线程栈,并在高位写入pvParam和pfnStratAddr。

每个线程都有其自己的一组CPU寄存器,称为线程上下文。上下文反映了线程上一次执行时,线程的CPU寄存器状态。当线程被重新调度时,保存在内核对象中的上下文将回写到CPU寄存器,以恢复线程的最后状态,这个过程称为“上下文切换”。其中最为重要的两个寄存器是堆栈寄存器(SP)和指令寄存器(IP),SP指向pfnStartAddr在堆栈中的地址,IP指向RtlUserThreadStart函数(NTDLL.dll导入)。Windows实际上提供了一个描述线程上下文的结构CONTEXT,并且提供了如下两个函数获得和设置上下文:

1
2
3
4
5
6
7
8
9
BOOL
WINAPI GetThreadContext(
  __in    
HANDLE hThread,
  __inout  LPCONTEXT lpContext
);
  
BOOL
WINAPI SetThreadContext(
  __in 
HANDLE hThread,
  __in 
const CONTEXT *lpContext
);

CONTEXT结构可能是Windows平台上唯一一个跟CPU有关的结构,所以如果要设置该结构可能需要考虑不同CPU的情况,而且如果设置不当,很有可能导致灾难性的后果。

线程完全初始化好之后,系统将检查CreateThread函数中的dwCreationFlags,如果此标记不是CREATE_SUSPENDED,系统将把挂起计数递减至0,以便处理器调度该线程。

 

Microsoft C/C++运行库注意事项

Visual Studio附带了4个C/C++运行库用于开发,还有两个面向.NET托管环境。现在所有的库都支持多线程开发,已经没有专门针对单线程开发的运行库(C\C++运行库的出现早于多任务操作系统,因此当时有支持单线程的运行库):

库名称 描述
LibCMt.lib 库的静态链接发行版本
LibCMtD.lib 库的静态链接调试版本
MSVCRt.lib 导入库,用于动态链接MSVCRxxx.dll库的发行版本
MSVCRtD.lib 导入库,用于动态链接MSVCRxxxD.dll库的调试版本(默认)
MSVCMRt.lib 导入库,用户托管/本机代码混合
MSVCURt.lib 导入库,编译成百分之百的纯MSIL代码

始终应该用运行库的_beginthreadex来创建线程,以及用_endthreadex来代替ExitThread。

 

线程的挂起、恢复和睡眠

如上文所讨论的,线程在创建的时候可以设置是否挂起。我们可以通过如下两个函数来挂起和恢复线程

1
2
3
4
5
6
7
DWORD
WINAPI SuspendThread(
  __in 
HANDLE hThread
);
  
DWORD
WINAPI ResumeThread(
  __in 
HANDLE hThread
);

调用SuspendThread将递增线程内核对象的挂起计数,可以挂起多次,对应的也可以恢复多次。另外,没有完美的“挂起进程”的函数,因为唯一的方法就是遍历并挂起进程中的所有线程,然而在遍历的过程中如果有新的线程创建了呢,亦或是刚遍历的线程销毁了呢。因此试图“挂起进程”要十分小心,尽量避免。

使用下面的函数指定挂起当前线程一段时间

1
2
3
VOID
WINAPI Sleep(
  __in 
DWORD dwMilliseconds
);
 
 

【Windows】线程漫谈——线程栈

本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

预备知识

众所周知,线程在初始化时,系统会为其分配线程栈,用于局部变量、函数调用时的参数等。在开始讨论前,先交代一些背景知识。

栈:一种先入后出的数据结构,push和pop是它典型的操作,对应“入栈”和“出栈”的术语。

系统内存的分配机制:简单的说包括“预订”和“调拨”两个过程。预订并不真正分配物理存储器,只是对进程虚拟地址空间中的内存进行“预分配”,以使得这块内存不至于被当前进程的其他指令分配;调拨就是为预订的内存空间分配物理存储器(windows中物理存储器可能是物理内存,也可能是内存页交换文件)。windows之所以这样做,归根到底是为了让进程以为自己占用了所有的物理存储器。内存以页面为单位,x86平台的页面大小为4KB,每个页面会有一个保护属性,例如:PAGE_READWRITE、PAGE_GUARD等。

 

栈内存结构和工作原理

默认情况下,线程栈的大小为1MB,对应256个页面。可以用Microsoft C++链接器的链接选项改变默认栈的大小,也可以通过CreateThread方法的参数改变。(最近看了点汇编的东西,我觉得事实上并非线程一定需要栈,而是C++链接器会自动在PE文件中写入初始化栈的信息,PE加载器能识别并初始化SS、SP。而编译器在处理变量和函数调用时会默认用这个栈)。我们知道x86的进栈指令PUSH总是递减栈顶指针,所以栈顺序总是从高位到低位。初始默认情况下,操作系统会为线程栈预订1MB的空间,并调拨2个页面的空间。下图为线程栈初始化的状态:

image

上图中我们看到,前两个页面是被调拨的,而只有第一个页面被设置成了PAGE_READWRITE,第二个页面被设置成PAGE_GUARD。随着调用越来越多的函数,线程需要越来越多的栈,当访问到第二个页面的时候(PAGE_GUARD),系统会得到通知,接下来系统会修改PAGE_GUARD为PAGE_READWRITE,并为下一个页面调拨存储器。

当线程访问到倒数第三个页面的的时候,系统会为倒数第二个页面调拨物理存储器,此时还会抛出EXCEPTION_STACK_OVERFLOW,如果用结构化异常处理掉了,并且线程继续使用栈空间,那么倒数第二个页面会被用尽,此时不得不访问栈底页面,然而栈底页面并没有被调拨,这时发生的访问违规将终止整个进程!这就是栈溢出错误!系统这么做自然是为了保护进程中的其他内存空间。

相比StackOverflow,还有一个StackUnderflow的错误。下面的代码展示了StackUnderflow

1
2
3
4
5
int
WINAPI WinMain(
HINSTANCE
hInstance,
HINSTANCE,PTSTR
pszCmdLine,int
nCmdShow){
    BYTE
aBytes[100];
    aBytes[10000H] = 0;//默认分配1MB的栈,此时访问了1MB以外的空间
    ...
}

如果此时,aBytes[10000H]处的内存没有被调拨,则会发生访问违规;如果已调拨了物理内存,则其他的内存被破坏。

 

C++运行库的栈检查函数

上面所述的调拨栈空间的策略看似“无懈可击”,可是“暗藏漏洞”。先看下面这段代码:

1
2
3
4
void
SomeFunction(){
    int
nValues[4000];
    nValues[0] = 0;//assign a value
}

在32位系统中,这个函数至少需要4000*4=16000字节,其中index为0的元素在哪里呢?在栈的低地址!如果以默认1MB的栈空间分配的话,nValues[0]将访问尚未调拨的空间。为了解决这个问题,编译器会自动插入栈检查代码。编译器能够计算出函数所需要的栈空间,如果所需要的空间大于一个页面的大小,编译器就会为函数插入检查代码。检查代码的原理很简单:每次试图访问下一个页面中的某个地址,以使系统自动为它分配调拨内存,直到需要的栈空间都满足为止。当然如果预设的栈空间不够的话,还是会先引发溢出异常。

 

【Windows】线程漫谈——线程同步之原子访问

 本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。

多线程同步的难题

我们知道单核处理器同一时刻只能处理一条指令,操作系统通过时间片调度实现了多任务和多线程。在这个过程中,操作系统随时会中断一个线程(这种中断是以指令为单位的),也就是说完全有可能在一个不确定的时候,线程用完了时间片,控制权交给了另一个线程,另一个线程用完时间片,控制权转回,但是这一进一出有可能一个被共享的全局变量的值已经变了!这也许会带来灾难性的后果,也许不会。因此,站在系统层面考虑,每当属于线程的时间片用完之后,系统要把当前CPU寄存器的值(比如,指令寄存器,栈指针寄存器)写入线程内核对象以“保存现场”,当线程再次获得时间片后,应该从内核对象中把上一次的“现场”恢复到CPU寄存器中。

需要强调的是,线程被中断的时间完全不确定。对于CPU来说,真正的“原子操作”应该是一条指令,而不是高级语言的语句。假设 g_x++ 这样的C语句操作需要如下的汇编指令:

1
2
3
MOV EAX, [g_x]
INC EAX
MOV [g_x], EAX

可能执行完第二句指令,新的g_x值还没有回写内存,线程的时间片到了,控制权交给了另外一个线程的,另一个线程也要操作g_x,那么结果将是不可预知的。

可见线程同步的难度似乎比我们想象的要大一些。幸好,Windows或各种语言或者各种类库为我们提供了很多线程同步的方法。这篇开始讨论Win32下的线程同步的话题。

 

原子访问:Interlocked系列函数

为了解决上面对g_x++这样的操作的原子访问(即保证g_x++不会被打断),可以用如下方法:

1
2
3
4
5
6
7
8
9
10
long
g_x = 0;
DWORD
WINAPI ThreadFunc1(
PVOID
pvParam){
    InterlockedExchangeAdd(&g_x,1);
    return(0);
}
  
DWORD
WINAPI ThreadFunc2(
PVOID
pvParam){
    InterlockedExchangeAdd(&g_x,1);
    return(0);
}

上面代码的InterlockedExchangeAdd保证加法运算以“原子访问”的方式进行。InterlockedExchangeAdd的工作原理根据不同的CPU会有所不同。但是,我们必须保证传给这些Interlocked函数的变量地址是经过对齐的。

所谓对齐,是指数据的地址模除数据的大小应该为0,比如WORD的起始地址应该能被2整除,DWORD的地址能被4整除。x86架构的CPU能够自动处理数据错位,而IA-64的处理器不能处理,而会将错误抛给Windows,Windows能决定是抛出异常还是帮助CPU处理错位。总之,数据错位不会导致错误,但由于CPU将至少多耗费一个读内存操作,因此将影响程序的性能。

InterlockedExchange用于以原子的方式设置一个32位的值,并返回它之前的值,可以用来实现旋转锁(spinlock):

1
2
3
4
5
6
7
8
9
10
11
12
//全局变量指示共享资源是否被占用
BOOL
g_fResourceInUse = FALSE;
...
void
Func1(){
    //等待共享资源释放
    while
( InterlockedExchange ( &g_fResourceInUse, TRUE ) == TRUE )
        Sleep(0);
    //访问共享资源
    ...
    //不再需要共享资源时释放
    InterlockedExchange ( &g_fResourceInUse, FALSE );
}

 

while循环不停的进行,并且设置g_fResourceInUse为TRUE,如果返回值为TRUE表示资源已经被占用,于是线程Sleep(0)意味着线程立即放弃属于自己的时间片,这样将导致CPU调度其他线程。如果返回值为FLASE,表示资源当前没有被占用,可以访问共享资源。不过在使用这项技术的时候要很小心,因为旋转锁将浪费CPU时间。

 

 

高速缓存行与volatile

众所周知,CPU拥有高速缓存,CPU高速缓存的大小是评判CPU性能的一个指标。现如今的CPU一般拥有3级的缓存,CPU总是优先从一级缓存中中读取数据,如果读取失败则会从二级缓存读取数据,最后从内存中读取数据。CPU的缓存由许多缓存行组成,对于X86架构的CPU来说,高速缓存行一般是32个字节。当CPU需要读取一个变量时,该变量所在的以32字节分组的内存数据将被一同读入高速缓存行,所以,对于性能要求严格的程序来说,充分利用高速缓存行的优势非常重要。一次性将访问频繁的32字节数据对齐后读入高速缓存中,减少CPU高级缓存与低级缓存、内存的数据交换。

但是对于多CPU的计算机,情况却又不一样了。例如:

  1. CPU1 读取了一个字节,以及它和它相邻的字节被读入 CPU1 的高速缓存。
  2. CPU2 做了上面同样的工作。这样 CPU1 , CPU2 的高速缓存拥有同样的数据。
  3. CPU1 修改了那个字节,被修改后,那个字节被放回 CPU1 的高速缓存行。但是该信息并没有被写入RAM 。
  4. CPU2 访问该字节,但由于 CPU1 并未将数据写入 RAM ,导致了数据不同步。

当然CPU设计者充分考虑了这点,当一个 CPU 修改高速缓存行中的字节时,计算机中的其它 CPU会被通知,它们的高速缓存将视为无效。于是,在上面的情况下, CPU2 发现自己的高速缓存中数据已无效, CPU1 将立即把自己的数据写回 RAM ,然后 CPU2 重新读取该数据。 可以看出,高速缓存行在多处理器上会导致一些不利。

以上背景知识对于我们编程至少有如下两个意义:

1、有些编译器会对变量进行优化,这种优化可能导致CPU对变量的读取指令始终指向高速缓存,而不是内存。这样的话,当一个变量被多个线程共享的时候,可能会导致一个线程对变量的设置始终无法在另一个线程中体现,因为另一个线程在另一个CPU上运行,并且变量的值在该CPU的高速缓存中!volatile关键字告诉编译器生成的代码始终从内存中读取变量,而不要做类似优化。

2、在多CPU环境下,合理的设置高速缓存对齐,以使得CPU之间的高速缓存同步动作尽量的少发生,以提升性能。要对齐高速缓存,首先要知道目标CPU的高速缓存行的大小,然后用__declspec(align(#))来告诉编译器为变量或结构设置指定符合高速缓存行大小的数据大小,例如:

1
2
3
4
struct
CACHE_ALIGN S1 { // cache align all instances of S1
   int
a, b, c, d;
};
struct
S1 s1;   // s1 is 32-byte cache aligned

更多内容可参见:http://msdn.microsoft.com/en-us/library/83ythb65.aspx

 

具体的,高速缓存行对齐的目标可以是:在结构中,把经常读操作的字段和经常写操作的字段分开,使得读操作的字段与写操作的字段出现在不同的高速缓存行中。这样就减少了CPU高速缓存行同步的次数,一定程度上提升了性能。

 

转自:http://www.cnblogs.com/P_Chou/archive/2012/06/10/basic-of-thread.html

抱歉!评论已关闭.