0%

多线程笔记:线程库的使用

背景

多线程概述:应用层的多线程的目的就是让每一个任务(例如一系列函数调用)都认为自己独占CPU资源,即宏观上,多个任务可以同时执行(实际可能是轮转的串行执行)。
代码实现:线程库可以由编程语言的标准库或者操作系统的库实现,具体包含的头文件如下:

  • C/C++ : < thread >
  • POSIX(Portable Operating System Interface of UNIX, Linux环境使用较多) :< pthread.h >
  • Windows OS : < windows.h >

具体环境使用哪个库,有不同的观点,参考
c++多线程编程主要用pthread还是c++11中的thread类?
即使是同一环境,也有不同封装层次的API
CreateThread()与_beginthread()的区别详细解析

主线程与工作线程:
一般应用程序都有主要的执行流程,例如C/C++的main入口函数,主要执行流程是在进程中执行的,也可以认为main是线程,独占了进程的全部资源,称为主线程。如果在该进程执行时,创建多个线程,用于并行处理其他任务,称为工作线程。

本文讲不同风格的线程创建\销毁,和访问共享数据的锁操作
本系列源码:cursorhu/SimpleMultiThread

Windows风格多线程

(1)双线程打印

#include <iostream>   
#include <windows.h>   
using namespace std;
 
DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;
    for (int i = 0; i < 10; i++)
        cout << s << endl;
    return 0;
}
 
int main()
{
    std::string s1 = "Work thread";
    std::string s2 = "Main thread";
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    CloseHandle(hThread);
    
    return 0;
}

主线程和工作线程都运行Print(),各线程的栈空间保存自己的局部数据。
windows API使用CreateThread和CloseHandle创建线程、释放线程句柄,说明如下

HANDLE CreateThread(
    LPSECURITY_ATTRIBUTES lpThreadAttributes,//SD:线程安全相关的属性,常置为NULL
    SIZE_T dwStackSize,//initialstacksize:新线程的初始化栈的大小,可设置为0
    LPTHREAD_START_ROUTINE lpStartAddress,//threadfunction:被线程执行的回调函数,也称为线程函数
    LPVOID lpParameter,//threadargument:传入线程函数的参数,不需传递参数时为NULL
    DWORD dwCreationFlags,//creationoption:控制线程创建的标志
    LPDWORD lpThreadId//threadidentifier:传出参数,用于获得线程ID,如果为NULL则不返回线程ID
    )
 
/*
lpThreadAttributes:指向SECURITY_ATTRIBUTES结构的指针,决定返回的句柄是否可被子进程继承,如果为NULL则表示返回的句柄不能被子进程继承。
dwStackSize:设置初始栈的大小,以字节为单位,如果为0,那么默认将使用与调用该函数的线程相同的栈空间大小。
任何情况下,Windows根据需要动态延长堆栈的大小。
lpStartAddress:指向线程函数的指针,函数名称没有限制,但是必须以下列形式声明:
DWORD WINAPI 函数名 (LPVOID lpParam) ,格式不正确将无法调用成功。
lpParameter:向线程函数传递的参数,是一个指向结构的指针,不需传递参数时,为NULL。
dwCreationFlags:控制线程创建的标志,可取值如下:
(1)CREATE_SUSPENDED(0x00000004):创建一个挂起的线程(就绪状态),直到线程被唤醒时才调用
(2)0:表示创建后立即激活。
(3)STACK_SIZE_PARAM_IS_A_RESERVATION(0x00010000):dwStackSize参数指定初始的保留堆栈的大小,
如果STACK_SIZE_PARAM_IS_A_RESERVATION标志未指定,dwStackSize将会设为系统预留的值
lpThreadId:保存新线程的id
返回值:函数成功,返回线程句柄,否则返回NULL。如果线程创建失败,可通过GetLastError函数获得错误信息。
*/
 
BOOL WINAPI CloseHandle(HANDLE hObject);        //关闭一个被打开的对象句柄
/*可用这个函数关闭创建的线程句柄,如果函数执行成功则返回true(非0),如果失败则返回false(0),
如果执行失败可调用GetLastError.函数获得错误信息。
*/

LPVOID 与 std::string类型的转换,需要用char*类型作中介,LPVOID接受buffer数组类型的转换
注意CloseHandle只是释放句柄资源,线程的资源释放是其函数执行完毕自动销毁的。

2次的运行结果

image-20221205152628474

可见,两个线程是随机切换的,导致如下现象:

  • Print()内的cout << s<<endl之间线程被切换,导致没有换行+双重换行。
  • 存在工作线程没执行完,主线程就执行完导致main return,整个进程销毁的情况。

改进如下:

#include <iostream>   
#include <windows.h>   
using namespace std;
 
DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;
    for (int i = 0; i < 10; i++)
        cout << s;
    return 0;
}
 
int main()
{
    std::string s1 = "Work thread\n";
    std::string s2 = "Main thread\n";
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    CloseHandle(hThread);
    Sleep(100);
    return 0;
}

使用以下方法解决上述问题

  • 主线程完成Print后,休眠100s,这个时间足够工作线程完成,Sleep结束后,main进程执行完毕

  • 把换行放到字符串中,使该字符串的完整打印成为不可被中途切换的操作,即原子操作

输出如下:
image-20221205152645608

如果Print有很多句打印,又不希望中途切换线程,如何做?

  • 互斥锁可以实现“大块代码的原子操作”
  • 锁是全局变量,因为主线程main和工作线程Print都能看到全局变量,而看不到对方的局部变量

代码如下:

#include <iostream>   
#include <windows.h>   
using namespace std;
 
HANDLE hMutex = NULL;//互斥锁的句柄

DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;

    for (int i = 0; i < 10; i++)
    {
        WaitForSingleObject(hMutex, INFINITE);//请求锁
        cout << s << endl;
        ReleaseMutex(hMutex);//释放锁
    }

    return 0;
}
 
int main()
{
    std::string s1 = "Work thread";
    std::string s2 = "Main thread";

    hMutex = CreateMutex(NULL, FALSE, NULL); //创建互斥锁
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    
    CloseHandle(hThread);
    CloseHandle(hMutex);//销毁互斥锁
    
    return 0;
}

运行结果:
image-20221205152654439
关于windows的互斥锁:

互斥量:
        采用互斥对象机制。互斥锁,像一个物件,这个物件只能同时被一个线程持有。 只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。
        一、创建 创建互斥锁的方法是调用函数CreateMutex: CreateMutex(&sa, bInitialOwner, szName);第一个参数是一个指向SECURITY_ATTRIBUTES结构体的指针,一般的情况下,可以是nullptr。 第二个参数类型为BOOL,表示互斥锁创建出来后是否被当前线程持有。 第三个参数类型为字符串(const TCHAR*),是这个互斥锁的名字,如果是nullptr,则互斥锁是匿名的。 例: HANDLE hMutex = CreateMutex(nullptr, FALSE, nullptr);上面的代码创建了一个匿名的互斥锁,创建出来后,当前线程不持有这个互斥锁。

         二、持有 WaitForSingleObject函数可以让一个线程持有互斥锁。用法: WaitForSingleObject(hMutex, dwTimeout);这个函数的作用比较多。这里只介绍第一个参数为互斥锁句柄时的作用。 它的作用是等待,直到一定时间之后,或者,其他线程均不持有hMutex。第二个参数是等待的时间(单位:毫秒),如果该参数为INFINITE,则该函数会一直等待下去。

        三、释放 用ReleaseMutex函数可以让当前线程“放开”一个互斥锁(不持有它了),以便让其他线程可以持有它。用法 ReleaseMutex(hMutex)

         四、销毁 当程序不再需要互斥锁时,要销毁它。 CloseHandle(hMutex)

         五、命名互斥锁 如果CreateMutex函数的第三个参数传入一个字符串,那么所创建的锁就是命名的。当一个命名的锁被创建出来以后,当前进程和其他进程如果试图创建相同名字的锁,CreateMutex会返回原来那把锁的句柄,并且GetLastError函数会返回ERROR_ALREADY_EXISTS。这个特点可以使一个程序在同一时刻最多运行一个实例

C++风格多线程

双线程分别实现计算和打印

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>

int g_mydata = 1;
std::mutex g_mutex;

void thread_func1()
{
    while (g_mydata < INT_MAX)
    {
        g_mutex.lock();
        ++g_mydata;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2()
{
    while (g_mydata < INT_MAX)
    {
        g_mutex.lock();
        std::cout << "g_mydata = " << g_mydata << ", ThreadID = " << std::this_thread::get_id() << std::endl;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    return 0;
}

几点说明:

  • C++使用< thread >调用线程库
  • std::thread t(thread_func)创建一个thread对象,传入参数为thread_fun,即线程内执行的函数
  • t.join()的含义是,线程t执行完毕后,join函数才能返回,主线程才能继续向后执行,宏观上就是,主线程被t线程阻塞在join函数处,这也许就是join的含义,t线程“加入”主线程的队伍,主线程必须原地等待t准备好了(执行完了)才能继续向后走。
  • 由于全局数据g_mydata和打印语句都不是原子操作,要保证完整操作,需要加锁,库定义在< mutex >
  • 为什么要sleep? 注意两个工作线程都while循环操作,sleep是手动使当前线程休眠,操作系统会轮换到其他active状态的线程执行,如果不sleep, 一个线程一直执行再被OS切换,间隔可能很久。< chrono >库用于时间
  • INT_MAX是C++定义的int类最大值,2^31-1

运行结果:
image-20221205152705047

POSIX/Linux风格

逻辑同上节,代码如下

#include <pthread.h>
#include <iostream>
#include <unistd.h>
#include <limits.h> //for INT_MAX

int g_mydata = 1;
pthread_mutex_t m;

void* thread_function1(void* args)
{
    while (g_mydata < INT_MAX)
    {
        pthread_mutex_lock(&m);
        ++g_mydata;
        pthread_mutex_unlock(&m);
        sleep(1);
    }
    
    return NULL;
} 

void* thread_function2(void* args)
{
    while (g_mydata < INT_MAX)
    {    
        pthread_mutex_lock(&m);
        std::cout << "g_mydata = " << g_mydata << ", ThreadID: " << pthread_self() << std::endl;
        pthread_mutex_unlock(&m);
        sleep(1);
    }
    
    return NULL;
} 

int main()
{
    pthread_mutex_init(&m, NULL);
    
    pthread_t threadIDs[2];    
    pthread_create(&threadIDs[0], NULL, thread_function1, NULL);
    pthread_create(&threadIDs[1], NULL, thread_function2, NULL);
    
    for(int i = 0; i < 2; ++i)
    {
        pthread_join(threadIDs[i], NULL);
    }
    
    pthread_mutex_destroy(&m);

    return 0;
}

win32应用程序使用pthread,需要配置pthread dll库,下载地址和配置方法:
pthreads-win32
VS2013 配置pthread

pthread的几个锁,参考:
linux线程互斥量pthread_mutex_t使用简介