使用C/C++运行时库函数操作线程

Visual Studio包含了4个本机C/C++运行时库和2个用来管理MS.NET的C/C++运行时库。所有这些库都支持多线程编程环境:目前已经没有专门为单线程开发设计的C/C++运行时库了。表6-1对这些库进行了描述:

Libray Name Description
LibCMt.lib Statically linked release version of the library.
LibCMtD.lib Statically linked debug version of the library.
MSVCRt.lib Import library for dynamically linking the release version of the MSVCR80.dll library. (This is the default library when you create a new project.)
MSVCRtD.lib Import library for dynamically linking the debug version of the MSVCR80D.dll library.
MSVCMRt.lib Import library used for mixed managed/native code.
MSVCURt.lib Import library compiled as 100-percent pure MSIL code.

当新建一个项目时,你应该知道当前项目链接的是哪一个库。在Visual Studio环境下,可以通过“项目属性”——“配置属性”——“C/C++”——“代码生成”——“运行时库”设置。

标准C运行时库是在1970年左右发布的,那时还没有线程这个概念,因此C运行时库的创造者们也没有意识到在多线程环境下使用C运行时库带来的问题。我们来举个例子,标准C运行时库中的全局变量errno用来表示最后一次函数调用的出错码,下面的代码段在多线程环境下可能会引发一些问题:

BOOL fFailure = (system("NOTEPAD.exe README.text") == -1);
if(fFailure){
	switch(errno){
	case E2BIG:
		...
		break;
	case ENOENT:
		...
		break;
	case ENOEXEC:
		...
		break;
	case ENOMEM:
		...
		break;
	}
}

假设当前线程在system调用后、if语句之前被中断,并且进程内的另外一个线程被调度且更改了errno的值,然后系统继续返回调度第一个线程,并判断errno,然而此时的errno已被另外的线程更改过,无法再准确的反应当system执行的状态了。为了解决这个问题,每个线程应该有自己的errno变量,此外应当保证每个线程只更改自己的errno而不会影响到其它线程。

这只是标准C/C++运行时库无法适应多线程环境的一个例子,事实上,在多线程环境下会出问题的C/C++运行时库变量和函数还包括(但不限于)_doserrno、strtok、_wcstok、strerror、_strerror、tmpnam、tmpfile、asctime、_wasctime、gmtime、_ecvt和_fcvt等等。

为了使多线程C/C++程序正常工作,使用C/C++运行时函数和变量的每一个线程都应该拥有自己的数据结构用来存储线程自身的C/C++运行时变量,在线程调用C/C++运行时函数时,这些函数将从线程自身的数据结构中找查找需要的数据而不会影响到其它线程。

系统如何知道要为新线程创建这样一个数据结构呢?答案是系统不知道,系统不知道你的应用程序是用C/C++写的,它也不知道你在其中调用了非线程安全的CRT函数。这一切都要由开发人员自己来做——用C/C++运行时库函数_beginthreadex创建新线程,而不是CreateThread系统函数:

unsigned long _beginthreadex(
   void *security,
   unsigned stack_size,
   unsigned (*start_address)(void *),
   void *arglist,
   unsigned initflag,
   unsigned *thrdaddr);

除了数据类型,_beginthreadex参数列表和CreateThread的完全相同,前者使用C风格的类型,而后者则使用Windows数据类型,因此在使用_beginthreadex时要注意数据类型的转换。下面是_beginthreadex函数的伪代码:

uintptr_t _cdecl _beginthreadex(
	void *psa,
	unsigned cbStackSize,
	unsigned (_stdcall *pfnStartAddr)(void *),
	void *pvParam,
	unsigned dwCreateFlags,
	unsigned *pdwThreadID) {
	
	_ptiddata ptd;		// Pointer to thread's data block
	uintptr_t thdl;		// Thread's handle
	
	// Allocate data block for the new thread
	if((ptd=(_ptiddata)_calloc_crt(1, sizeof(struct _tiddata))) == NULL)
		goto error_return;
	
	// Initialie the data block
	initptd(ptd);

	// Save the desired thread function and the parameter
	// we want it to get in the data block
	ptd->_initaddr = (void *)pfnStartAddr;
	ptd->_initarg = pvParam;
	ptd->_thandle = (uintptr_t)(-1);

	//Create the new thread
	thdl = (uintptr_t) CreateThread((LPSECURITY_ATTRIBUTES)psa, cbStackSize, 
		_threadstartex, (PVOID)ptd, dwCreateFlags, pdwThreadID);
	if(thdl == 0){
		// Thread couldn't be created, cleanup and return failure
		goto error_return;
	}
	return (thdl);

error_return:
	_free_crt(ptd);
	return ((uintptr_t)0L);
}

关于上面的代码,有以下几点需要注意:

  • _beginthreadex会在C/C++运行时堆中为每个线程分配一个_tiddata结构(见代码中的_ptiddata ptd)
  • 传递给_beginthreadex的入口点函数地址pfnStartAddr被保存在_tiddata结构中,pfnStartAddr的参数pvParam也保存在_tiddata结构中
  • _beginthreadex在其内部调用了CreateThread创建线程,因为这是操作系统创建线程的唯一途径
  • _beginthreadex为CreateThread传递的入口点函数并不是pfnStartAddr,而是_threadstartex,且参数是定义的_tiddata结构指针也不是pvParam,_threadstartex会在下面讲到
  • _beginthreadex调用成功时返回新线程句柄,否则返回0

 

下面是_tiddata结构的定义,比较冗长,不喜欢看就跳过,对下文的理解没什么影响:

struct _tiddata {
   unsigned long    _tid;      /* thread ID */

   unsigned long    _thandle;  /* thread handle */

   int     _terrno;            /* errno value */
   unsigned long   _tdoserrno; /* _doserrno value */
   unsigned int    _fpds;      /* Floating Point data segment */
   unsigned long   _holdrand;  /* rand() seed value */
   char*           _token;     /* ptr to strtok() token */
   wchar_t*        _wtoken;    /* ptr to wcstok() token */
   unsigned char*  _mtoken;    /* ptr to _mbstok() token */

   /* following pointers get malloc'd at runtime */
   char*       _errmsg;        /* ptr to strerror()/_strerror() buff */
   wchar_t*    _werrmsg;       /* ptr to _wcserror()/__wcserror() buff */
   char*       _namebuf0;      /* ptr to tmpnam() buffer */
   wchar_t*    _wnamebuf0;     /* ptr to _wtmpnam() buffer */
   char*       _namebuf1;      /* ptr to tmpfile() buffer */
   wchar_t*    _wnamebuf1;     /* ptr to _wtmpfile() buffer */
   char*       _asctimebuf;    /* ptr to asctime() buffer */
   wchar_t*    _wasctimebuf;   /* ptr to _wasctime() buffer */
   void*       _gmtimebuf;     /* ptr to gmtime() structure */
   char*       _cvtbuf;        /* ptr to ecvt()/fcvt buffer */

   unsigned char _con_ch_buf[MB_LEN_MAX];
                               /* ptr to putch() buffer */

   unsigned short _ch_buf_used;   /* if the _con_ch_buf is used */

   /* following fields are needed by _beginthread code */
   void*       _initaddr;      /* initial user thread address */
   void*       _initarg;       /* initial user thread argument */

   /* following three fields are needed to support signal handling and runtime errors */
   void*       _pxcptacttab;   /* ptr to exception-action table */
   void*       _tpxcptinfoptrs;/* ptr to exception info pointers */
   int         _tfpecode;      /* float point exception code */

   /* pointer to the copy of the multibyte character information used by the thread */
   pthreadmbcinfo ptmbcinfo;

   /* pointer to the copy of the locale information used by the thread */
   pthreadlocinfo ptlocinfo;
   int         _ownlocale;     /* if 1, this thread owns its own locale */

   /* following field is needed by NLG routines */
   unsigned long   _NLG_dwCode;

   /*
   * Per-Thread data needed by C++ Exception Handling
   */
   void*       _terminate;     /* terminate() routine */

   void*       _unexpected;    /* unexpected() routine */
   void*       _translator;    /* S.E. translator */
   void*       _purecall;      /* called when pure virtual happens */
   void*       _curexception;  /* current exception */
   void*       _curcontext;    /* current exception context */
   int         _ProcessingThrow; /* for uncaught_exception */
   void*       _curexcspec;    /* for handling exceptions thrown from std::unexpected */

#if defined (_M_IA64) || defined (_M_AMD64)
   void*     _pExitContext;
   void*     _pUnwindContext;
   void*     _pFrameInfoChain;
   unsigned __int64 _ImageBase;
#if defined (_M_IA64)
   unsigned __int64 _TargetGp;
#endif /* defined (_M_IA64) */
   unsigned __int64 _ThrowImageBase;
   void*     _pForeignException;
#elif defined (_M_IX86)
   void*     _pFrameInfoChain;
#endif /* defined (_M_IX86) */
   _setloc_struct _setloc_data;

   void*     _encode_ptr;     /* EncodePointer() routine */
   void*     _decode_ptr;     /* DecodePointer() routine */

   void*    _reserved1;      /* nothing */
   void*    _reserved2;      /* nothing */
   void*    _reserved3;      /* nothing */

   int _     cxxReThrow;     /* Set to True if it's a rethrown C++ Exception */

   unsigned long __initDomain;    /* initial domain used by _beginthread[ex] for managed
function */
};

typedef struct _tiddata * _ptiddata;

 

_beginthreadex为每个线程创建了_tiddata结构,那么_tiddata和当前线程是如何关联的呢?让我们再来看看_threadstartex函数的伪代码:

static unsigned long WINAPI _threadstart(void* ptd){
	// Note: ptd is the address of this thread's tiddata block
	
	// Associate the tiddata block with this thread so
	// _getptd() will be able to find it in _callthreadstartex
	TlsSetValue(_tlsindex, ptd);
	
	// Save this thread id in the tiddata block
	((_ptiddata) ptd)->_tid = GetCurrentThreadId();
	
	// Initialize floating-point support (code not shown)

	// call helper function
	_callthreadstartex();

	// We never get here, the thread dies in _callthreadstartex
	return (0L);
}

static void _callthreadstartex(void){
	// Pointer to the thread's tiddata struct
	_ptiddata ptd;
	ptd = _getptd();
	
	// Wrap desired thread function in SEH frame to
	// handle run-time errors and signal support
	__try{
		_endthreadex(((unsigned (WINAPI *)(void *))(((_ptiddata)ptd)->_initaddr))(((_ptiddata)ptd)->_initarg));
	}
	__except(_XcptFilter(GetExceptionCode(), GetExceptionInformation())){
		// The C run-time's exception handler deals with run-time errors
      		// and signal support; we should never get it here.
      		_exit(GetExceptionCode());
	}
}

_threadstartex有下面几点值得注意:

  • 新线程首先执行RtlUserThreadStart,并在其中调用_threadstartex
  • _threadstartex的参数是在_beginthreadex中分配的_tiddata结构的指针
  • TlsSetValue用来将指定参数和当前调用线程相关联,这被称为“线程局部存储”。通过调用TlsSetValue,_threadstartex函数将_tiddata结构与新线程相关联
  • _callthreadstartex函数中使用了结构化异常处理框架来处理和运行时库相关的一些问题——比如运行时错误和C/C++运行时库函数signal等,这是很重要的,因为假如你用CreateThread创建了新线程并调用了signal函数,可能会引发错误
  • _callthreadstartex函数中调用了传递给_beginthreadex的线程入口点函数,并向其传递了相应的参数
  • 线程入口点函数的返回值将作为线程的退出码。_callthreadstartex并不会返回到调用它的_threadstartex中,它调用_endthreadex,并在其中结束了整个线程

 

我们再来看看_endthreadex函数的伪代码:

void __cdecl _endthreadex(unsigned retcode) {
	// Pointer to thread's data block
	_ptiddata ptd;

	// Clean up floating-point support (code not shown)

	// Get the address of this thread's tiddata block
	ptd = _getptd_noexit();

	//Free the tiddata bloc
	if(ptd != NULL)
		_freeptd(ptd);
	
	// Terminate the thread
	ExitThread(retcode);
}

关于_endthreadex,有以下两点需要注意:

  • C运行时库函数_getptd_noexit()会调用TlsGetValue以获取当前线程的_tiddata结构
  • _freeptd将从C/C++运行时堆中销毁当前线程的_tiddata结构,接着_endthreadex调用ExitThread函数彻底销毁该线程

 

之前我提到过,开发人员应该避免使用ExitThread,由于ExitThread并不会返回到调用函数而是使线程在其内部终止,因此线程用到的C/C++对象将无法销毁,此外从上面的代码可以看出,如果线程由_beginthreadex创建,且在执行时被ExitThread终止,其_tiddata结构将无法从堆中销毁,造成内存泄露(直到进程终止)。对于_beginthreadex创建的函数,如果必须强迫其中止,应该调用_endthreadex,线程的_tiddata结构将会在_endthreadex中被正确销毁。

当线程的_tiddata结构初始化完成并和线程相关联之后,线程调用的任何需要该结构的C/C++运行时库函数就可以很方便的通过TlsGetValue得到它并且对其进行操作了。你可能会好奇,对于errno这样的全局变量,这种线程间的独立性是如何做到的,errno的定义揭示了这一点:

_CRTIMP extern int * __cdecl _errno(void);
#define errno (*_errno())

int * __cdecl _errno(void) {
	_ptiddata ptd = _getptd_noexit();
	if(!ptd){
		return &ErrnoMem;
	} else {
		return (&ptd->_terrno);
	}	
}

当你在代码中引用errno时,事实上会调用C/C++运行时库函数_errno,_errno返回当前线程_tiddata结构中存储中的_terrno变量的地址,该变量就是当前线程最后一次运行时产生的错误码。注意_errno函数返回的是_terrno的地址,errno宏对其做了解引用,返回了一个左值,这样你就可以编写类似下面的代码:

int *p = &errno;
if(*p == ENOMEM){
	...
}

 

C/C++运行时中的某些函数可能会遇到线程同步的问题。比如当两个线程同时调用malloc函数时,进程堆可能被破坏。C/C++运行时库通过同步机制避免了多个malloc的同时执行,多个malloc调用将被排队调度,显然这种额外的工作会影响多线程版本的C/C++运行时库的性能。

C/C++运时时库的动态链接版本是通用的,这意味着它可以被所用使用C/C++运行时库函数的程序和DLL调用,因此它也只有多线程版本。由于C/C++运行时库被封装在DLL中,使用其中函数的应用程序和DLL模块就不必再包含C/C++运行时代码,因此产生的目标模块的体积更小。

上述讨论对于进程的主线程也是适用的。

你可能想知道假如新线程用CreateThread而不是_beginthreadex创建时会发生什么——当线程调用一个需要_tiddata结构的C/C++运行时函数时,首先,被调用的C/C++运行时函数会尝试从当前线程中获取_tiddata结构的地址(通过TlsGetValue),假如得到的值为NULL,这意味着没有_tiddata结构与当前线程相关联,此时C/C++运行时函数会创建并初始化一个_tiddata结构,并将其与当前线程关联起来(通过TlsSetValue),该结构将一直存在于当前线程中直至线程终止。这样当前线程调用的所有C/C++运行时函数便可以使用该数据块了。

上面的机制非常奇妙,然而这其中存在一些问题。首先,假如线程调用了C/C++运行时库函数signal,由于当前线程并没有准备结构化异常处理框架,整个进程会被终止(参见RtlUserThreadStart的伪代码),其次,这样创建的_tiddata结构在线程结束时无法被销毁,除非调用_endthreadex函数。可是,谁会想到用_endthreadex来结束CreateThread函数创建的线程呢?

C/C++运行时库还包含另外两个用于线程管理的函数:

unsigned long _beginthread(
	void (_cdecl *start_address)(void *),
	unsigned stack_size,
	void *arglist
);

void _endthread(void);

这两个函数的作用分别和_beginthreadex以及_endthreadex类似,但是也存在较大的差异。由_beginthread的参数可以看出,_beginthread不能指定新线程的安全属性、不能暂停新线程的调度、无法获取新线程的id,而_endthread的参数列表为空,表示线程的退出码将硬编码为0。

 

_endthread有一个隐藏的严重问题,在它调用ExitThread之前,它会执行CloseHandle关闭当前线程的句柄,因此类似下面的代码会引发问题:

DWORD dwExitCode;
HANDLE hThread = _beginthread(...);
GetExitCodeThread(hThread, &dwExitCode);
CloseHandle(hThread);

新创建的线程可能在GetExitCodeThread调用之前结束,此时由于_endthread在其内部调用了CloseHandle,hThread已变成无效句柄,因此GetExitCodeThread的调用会失败,出于同样的原因CloseHandle的调用也会失败。

 

_endthreadex并不会关闭线程句柄,因此上面的代码假如使用_beginthreadex代替_beginthread的话就不会出现问题。记住当线程返回时,_beginthreadex会调用_endthreadex,而_beginthread调用_endthread。

你可能感兴趣的:(使用C/C++运行时库函数操作线程)