Windows для профессионалов

       

Некоторые соображения по библиотеке С/С++


Microsoft поставляет с Visual С++ шесть библиотек С/С++. Их краткое описание представлено в следующей таблице.



Имя библиотеки

Описание

LibC.lib

Статически подключаемая библиотека для однопоточных приложений

LibCD.lih

Отладочная версия статически подключаемой библиотеки для однопо

LibCMt.lib

Статически подключаемая библиотека для многопоточных приложений

LibCMtD.lib

Отладочная версия статически подключаемой библиотеки для много

MSVCRt.lib

Библиотека импорта для динамического подключения рабочей версии

MSVCRtD.lib

Библиотека импорта дли динамического подключения отладочной версии MSVCRtD.dll; поддерживает как одно-, так и многопоточные приложения

При реализации любого проекта нужно знать, с какой библиотекой его следует связать. Конкретную библиотеку можно выбрать в диалоговом окне Project Settings: на вкладке С/С++ в списке Category укажите Code Generation, а в списке Use Run-Time Library — одну из шести библиотек.

Наверное, Вам уже хочется спросить: "А зачем мне отдельные библиотеки для однопоточных и многопоточных программ?" Отвечаю. Стандартная библиотека С была разработана где-то в 1970 году — задолго до появления самого понятия многопоточности. Авторы этой библиотеки, само собой, не задумывались о проблемах, связанных с многопоточными приложениями.

Возьмем, к примеру, глобальную переменную errno из стандартной библиотеки С. Некоторые функции, если происходит какая-нибудь ошибка, записывают в эту переменную соответствующий код. Допустим, у Вас есть такой фрагмент кода:

BOOL fFailure = (system("NOTEPAD.EXE README.TXT") == -1);

if (fFailure)
{
switch (errno)
{
case E2BIG:
// список аргументов или размер окружения слишком велик
break;

case ENOENT:
// командный интерпретатор не найден
break;

case ENOEXEC;
// неверный формат командного интерпретатора
break;

case ENOMEM:
// недостаточно памяти для выполнения команды
break;
}

Теперь представим, что поток, выполняющий показанный выше код, прерван после вызова функции system и до оператора if.
Допустим также, поток прерван для выполнения другого потока (в том же процессе), который обращается к одной из функций библиотеки С, и та тоже заиосит какое то значение в глобальную переменную errno. Смотрите, что получается когда процессор вернется к выполнению первого потока, в переменной errno окажется вовсе не то значение, которое было записано функцией system. Поэтому для решения этой проблемы нужно закрепить за каждым потоком свою переменную errno. Кроме того, понадобится какой-то механизм, который позволит каждому потоку ссылаться на свою переменную errno и не трогать чужую.

Это лишь один пример того, что стандартная библиотека С/С++ не рассчитана на многопоточные приложения. Кроме errno, в ней есть еще целый ряд переменных и функций, с которыми возможны проблемы в многопоточной среде _doserrno, strtok, _wcstok, strerror, _strerror, tmpnam, tmpfile, a<tcttme, _wascttme, gmttme, _ecvt, _Jcvt - список можно продолжить.

Чтобы многопоточные программы, использующие библиотеку С/С++, работали корректно, требуется создать специальную структуру данных и связать ее с каждым потоком, из которого вызываются библиотечные функции. Более того, они должны знать, что, когда Вы к ним обращаетесь, нужно просматривать этот блок данных в вызывающем потоке чтобы не повредить данные в каком-нибудь другом потоке.

Так откуда же система знает, что при создании нового потока надо создать и этот блок данных3. Ответ очень прост - не знает и знать не хочет. Вся ответственность — исключительно на Вас. Если Вы пользуетесь небезопасными в многопоточной среде функциями, то должны создавать потоки библиотечной функцией _begmthreadex, а не Windows-функцией CreateThread.

unsigned long _beginthreadex( void *secunty unsigned stack size unsigned (*start_address)(void *) void *arglist unsigned initflag unsigned *thrdaddr)

У функции _beginthreadGX тот же список параметров, что и у CreateThread, но их имена и типы несколько отличаются. (Группа, которая отвечает в Microsoft за разработку и поддержку библиотеки С/С++, считает, что библиотечные функции не должны зависеть от типов данных Windows).


Как и CreateTbread, функция _beginthreadex возвращает описатель только что созданного потока. Поэтому, если Вы раньше пользовались функцией CreateThread, её вызовы в исходном коде несложно заменить на вызовы _begtnthreadex. Однако из-за некоторого расхождения в типах данных Вам придется позаботиться об их приведении к тем, которые нужны функции _begin threadex, и тогда компилятор будет счастлив. Лично я создал небольшой макрос chBEGINTHREADEX, который и делает всю эту работу в исходном коде:

typedef unsigned ( stdcall *PTHREAD START) (void *)

#define chBEGINTHREADEX(psa cbStack pfnStartAddr \
pvParam fdwCreate pdwThreadID) \
((HANDLE) _beginthreadex( \
(void *) (psa) \
(unsigned) (cbStack), \
(PTHREAD_START) (pfnStartAddr) \
(void *) (pvParam) \
(unsigned) (fdwCreate) \
(unsigned *) (pdwThreadID)))

Заметьте, что функция _beginthreadex существует только в многопоточных версиях библиотеки С/С++. Связав проект с однопоточной библиотекой, Вы получите от компоновщика сообщение об ошибке "unresolved external symbol". Конечно, это сделано специально, потому что однопоточная библиотека не может корректно работать в многопоточном приложении. Также обратите внимание на то, что при создании нового проекта Visual Studio по умолчанию выбирает однопоточную библиотеку. Этот вариант не самый безопасный, и для многопоточных приложений Вы должны сами выбрать одну из многопоточных версий библиотеки С/С++.

Поскольку Microsoft поставляет исходный код библиотеки С/С++, несложно разобраться в том, что такого делает _beginthreadex, чего не делает CreateThread. На дистрибутивном компакт-диске Visual Studio ее исходный код содержится в файле Threadex.c. Чтобы не перепечатывать весь код, я решил дать Вам её версию в псевдокоде, выделив самые интересные места.

unsigned long _cdocl _beginthreadex ( void *psa, unsigned cbStack,
unsigned (__stdcall * pTnStartAddr) (void *), void *pvParam, unsigned fdwCreate, unsigned *pdwThreadID)
{
_ptiddata ptd;
// указатель на блок данных потока unsigned long thdl,


// описатель потока
// выделяется блок данных для нового потока

if ((ptd = _calloc_crt(1, sizeof(struct tiddata))) == NULl)
goto error_returnж

// инициализация блока данных
initptd(ptd);

// здесь запоминается нужная функция потока и параметр,
// который мы хотим поместить в блок данных
ptd->_initaddr = (void *) pfnStartAddr;
ptd->_initarg = pvParam;

// создание Honoio потока

thdl = (unsigned long)
CreateThread(psa, cbStack, _threadstartex, (PVOID) ptd, fdwCreate, pdwThrcadID);

if (thdl == NULl) {
// создать поток не удалось, проводится очистка и сообщается об ошибке
goto error_return;
}

// поток успешно создан; возвращается его описатель
return(thdl);

error_return:
// ошибка! не удалось создать блок данных или сам поток
_free_crt(ptd);

return((unsigned long)0L);

}

Несколько важных моментов, связанных с _beginthreadex

  • Каждый поток получает свой блок памяти tiddata, выделяемый из кучи, которая принадлежит библиотеке С/C++. (Структура tiddata определена в файле Mtdll h. Она довольно любопытна, и я привел ее на рис 6-2.)
  • Адрес функции потока, переданный _beginthreadex, запоминается в блоке памяти tiddata. Там же сохраняется и параметр, который должен быть передан этой функции.
  • Функция _beginthreadex вызывает CreateThread, так как лишь с ее помощью операционная система может создать новый поток.
  • При вызове CreateThread сообщается, что она должна начатъ выполнение нового потока с функции _threadstartex, а не с того адреса, на который указывает fnStartAddr. Кроме того, функции потока передается не параметр рvParam, а адрес структуры tiddata.
  • Если все проходит успешно, beginthreadex, как и CreateThread, возвращает описатель потока. В ином случае возвращается NULL.

    struct tiddata
    {
    unsigned long _tid; /* идентификатор потока */
    unsigned long _thandle; /* описатель потока */
    int terrno; /* значение errno */
    unsigned long tdoserrno; /* значение _doserrno */
    unsigned int _fpds; /* сегмент данных Floating Point */
    unsigned lonq _holdrand; /* зародышевое значение для rand() */


    char * _token; /* указатель (ptr) на метку strtok() */

    #ifdef _WIN32
    wchar_t *_wtoken; /* ptr на метку wcstok() */
    #endif /* _WIN32 */

    unsigned char * _mtoken; /* ptr на метку _mbstok() */

    /* следующие указатели обрабатываются функцией malloc в период выполнения */
    char * _errmsg; /* ptr на буфер strerror()/_strerror() */
    char * _namebuf0; /* ptr на буфер tmpnam() */

    #ifdef _WIN32
    wchar_t * _wnarnebuf0; /* ptr на буфер_wtmpnam() */
    #endif /* _WIN32 */

    char * _namebuf1 /* ptr на буфер tmpfile() */

    #ifdef _WIN32
    wchar_t * _wnamebuf1; /* ptr ма буфер wTmpfi]e() */
    #endif /* _WIN32 */

    char * _asctimebuf; /* ptr на буфер asctime() */

    #ifdef _WIN32
    wchar_t * _wasctimebuf; /* ptr на буфер _wasctime() */
    #endif /* _WIN32 */

    void * _gmtimebuf; /* ptr на структуру gmtime() */
    char * _cvtbuf; * /* ptr на буфер ecvt()/fcvt */

    /* следующие поля используются кодом _beginthread */
    void * _initaddr; /* начальный адррс пользовательское потока */
    void * _initarg; /* начальный аргумент пользовательского потока */

    /* следующие три поля нужны для поддержки функции signal и обработки ошибок, возникающих в период выполнения */

    void * _pxcptaottab; /* ptr на таблицу исключение-действие */
    void * _tpxcptaofoptrs; /* ptr на указагели к информации об исключении */
    int _tfpecode; /* код исключения для операций над числами с плавающей точкой */

    /* следующее поле нужно подпрограммам NLG */
    unsigned long _NLG_dwCode;

    /* данные для отдельного потока используемые при обработке исключений в С++ */

    void * _terminate; /* подпрограмма terminate() */
    void * _unexpected; /* подпрограмма unexpected() */
    void * _translator; /* транслятор S E */
    void * _curexception; /* текущее исключение */
    void * _curcontext; /* контекст текущего исключения */

    #if defined (_M_MRX000)
    void * _pFrameInfoChain;
    void * _pUnwindContext;
    void * _pExitContext,
    int _MipsPtdDelta;
    int _MipsPtdEpsilon;
    #elif defined (_M_PPC)
    void * __pExitContext;
    void * _pUnwindContext;
    void * _pFrameInfoChain;
    int _FrameInfo[6];
    #endif /* defined (_M_PPC) */



    };

    typedef struct _tiddata * _ptiddata;



  • Рис. 6-2. Локальная структура tiddata потока, определенная в библиотеке С/С++

    Выяснив, как создается и инициализируется структура tiddata для нового потока, посмотрим, как она сопоставляется с этим потоком. Взгляните на исходный код функции _threadstartex (который тоже содержится в файле Threadex с библиотеки С/С++). Вот моя версия этой функции в псевдокоде:

    static unsigned long WINAPI threadstartex (void* ptd)
    {

    // Примечание ptd - это адрес блока tiddata данного потока
    // блок tiddata сопоставляется с данным потоком

    TlsSetValue( __tlsindex ptd);

    // идентификатор этого потока записывается в tiddata
    ((_ptiddata) ptd)->_tid = GetCurrentThreadId();
    // здесь инициализируется поддержка операций над числами с плавающей точкой
    // (код не показан)

    // пользовательская функция потока включается в SEH-фрейм для обработки
    // ошибок периода выполнения и поддержки signal
    __try
    {

    // здесь вызывается функция потока, которой передается нужный параметр;
    // код завершения потока передается _endthreadex
    _endthreadex( ( (unsigned (WINAPI *)(void *))(((_ptiddata)ptd)->_initaddr) ) ( ((_ptiddata)ptd)->_initarg ) ) ;

    }

    _except(_XcptFilter(GetExceptionCode(), GetExceptionInformation()))
    {
    // обработчик исключений из библиотеки С не даст нам попасть сюда
    _exit(GetExceptionGode());

    }

    // здесь мы тоже никогда не будем, так как в этой функции поток умирает

    return(0L);
    }

    Несколько важных моментов, связанных со _threadstartex.

  • Новый поток начинает выполнение с BaseThreadStart (в Kernel32.dll), а затем переходит в _threadstartex.
  • В качестве единственного параметра функции _threadstartex передается адрес блока tiddata нового потока.
  • Windows-функция TlsSetValue сопоставляет с вызывающим потоком значение, которое называется локальной памятью потока (Thread Local Storage, TLS) (о ней я расскажу в главе 21), a _threadstartex сопоставляет блок tiddata с новым потоком.
  • Функция потока заключается в SEH-фрейм. Он предназначен для обработки ошибок периода выполнения (например, не перехваченных исключений С++), поддержки библиотечной функции signal и др.


    Этот момент, кстати, очень важен. Если бы Вы создали поток с помощью CreateThread, а потом вызвали библиотечную функцию signal, она работала бы некорректно.
  • Далее вызывается функция потока, которой передается нужный параметр. Адрес этой функции и ее параметр были сохранены в блоке tiddata функцией _beginthreadex.
  • Значение, возвращаемое функцией потока, считается кодом завершения этого потока. Обратите внимание: _threadstartex не возвращается в BaseThreadStart. Иначе после уничтожения потока его блок tiddata так и остался бы в памяти. А это привело бы к утечке памяти в Вашем приложении. Чтобы избежать этого, threadstartex вызывает другую библиотечную функцию, _endthreadex, и передает ей код завершения.


  • Последняя функция, которую нам нужно рассмотреть, — это _endthreadex (ее исходный код тоже содержится в файле Threadex.c). Вот как она выглядит в моей версии (в псевдокоде).

    void _cdecl _endthreadex (unsigned retcode)
    {
    _ptiddata ptd;
    // указатель на блок данных потока

    // здесь проводится очистка ресурсов, выделенных для поддержки операций
    // над числами с плавающей точкой (код не показан)

    // определение адреса блока tiddata данного потока
    ptd = _getptd();

    // высвобождение блока tiddata
    _freeptd(ptd);

    // завершение потока
    ExitThread(retcode);
    }

    Несколько важных моментов, связанных с _endthreadex.

  • Библиотечная функция _getptd обращается к Windows-функции TlsGetValue, которая сообщает адрес блока памяти tiddata вызывающего потока.
  • Этот блок освобождается, и вызовом ExttThread поток разрушается. При этом, конечно, передается корректный код завершения.


  • Где-то в начале главы я уже говорил, что прямого обращения к функции ExitThread следует иpбегать. Это правда, и я не отказываюсь от своих слов. Тогда же я сказал, что это приводит к уничтожению вызывающего потока и не позволяет ему вернуться из выполняемой в данный момент функции. А поскольку она не возвращает управление, любые созданные Вами С++-объекты не разрушаются. Так вот, теперь у Вас есть еще одна причина не вызывать ExitThread, она не дает освободить блок памяти tiddata потока, из-за чего в Вашем приложении может наблюдаться утечка памяти (до его pавершения).



    Разработчики Microsoft Visual C++, конечно, прекрасно понимают, что многие все равно будут пользоваться функцией ExitThread, поэтому они кое-что сделали, чтобы свести к минимуму вероятность утечки памяти. Если Вы действительно так хотите самостоятельно уничтожить свой поток, можете вызвать из него _endthreadex (вместо ExitTbread) и тем самым освободить его блок tiddata. И все же я не рекомендую этого.

    Сейчас Вы уже должны понимать, зачем библиотечным функциям нужен отдельный блок данных для каждого порождаемого потока и каким образом после вызова _beginthreadex происходит создание и инициализация этого блока данных, а также его связывание с только что созданным потоком. Кроме того, Вы уже должны разбираться в том, как функция _endthreadex освобождает этот блок по завершении потока.

    Как только блок данных инициализирован и сопоставлен с конкретным потоком, любая библиотечная функция, к которой обращается поток, может легко узнать адрес его блока и таким образом получить доступ к данным, принадлежащим этому потоку.

    Ладно, с функциями все ясно, теперь попробуем проследить, что происходит с глобальными переменными вроде errno. В заголовочных файлах С эта переменная определена так:

    #if defined(_MT) || defined(_DLL)
    extern int * _cdecl _errno(void);
    #define errno (*_еггпо())
    #else /* ndef _MT && ndef _DLL */
    extern int errno;
    #endif /* MT | | _DLL */

    Создавая многопоточное приложение, надо указывать в командной строке ком пилятора один из ключей /MT (многопоточное приложение) или /MD (многопоточная DLL); тогда компилятор определит идентификатор _MT. После этого, ссылаясь на errno, Вы будете на самом деле вызывать внутреннюю функцию _errno из библиотеки С/С++. Она возвращает адрес элемента данных errno в блоке, сопоставленном с вызывающим потоком. Кстати, макрос errno составлен так, что позволяет получать co держимое памяти по этому адресу. А сделано это для того, чтобы можно было писать, например, такой код:

    int *p = &errno;

    if (*p == ENOMEM){
    ...


    }

    Если бы внутренняя функция _errno просто возвращала значение errno, этот код не удалось бы скомпилировать.

    Многопоточная версия библиотеки С/С++, кроме того, "обертывает" некоторые функции синхронизирующими примитивами. Ведь если бы два потока одновременно вызывали функцию malloc, куча могла бы быть повреждена. Поэтому в многопоточной версии библиотеки потоки не могут одновременно выделять память из кучи. Второй поток она заставляет ждать до тех пор, пока первый не выйдет из функции malloc, и лишь тогда второй поток получает доступ к malloc. (Подробнее о синхрони зации потоков мы поговорим в главах 8, 9 и 10.)

    Конечно, все эти дополнительные операции не могли не отразиться на быстро действии многопоточной версии библиотеки. Поэтому Microsoft, кроме многопоточной, поставляет и однопоточную версию статически подключаемой библиотеки С/С++.

    Динамически подключаемая версия библиотеки С/С++ вполне универсальна ее могут использовать любые выполняемые приложения и DLL, которые обращаются к библиотечным функциям. По этоЙ причине данная библиотека существует лишь в многопоточной версии. Поскольку она поставляется в виде DLL, ее код не нужно включать непосредственно в EXE- и DLL-модули, что существенно уменьшает их размер. Кроме того, если Microsoft исправляет какую-то ошибку в такой библиотеке, то и программы, построенные на ее основе, автоматически избавляются от этой ошибки.

    Как Вы, наверное, и предполагали, стартовый код из библиотеки С/С++ создает и инициализирует блок данных для первичного потока приложения. Это позволяет без всяких опасений вызывать из первичного потока любые библиотечные функции. А когда первичный поток заканчивает выполнение своей входной функции, блок данных завершаемого потока освобождается самой библиотекой. Более того, стартовый код делает все необходимое для сгруктурной обработки исключений, благодаря чему из первичного потока можно спокойно обращаться и к библиотечной функции signal.


    Содержание раздела