MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++全局变量引用的多线程问题

2023-07-201.4k 阅读

C++全局变量引用的多线程问题

一、多线程编程基础

在深入探讨 C++全局变量引用的多线程问题之前,我们先来回顾一下多线程编程的基础知识。

(一)线程的概念

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。通过多线程编程,程序可以同时执行多个任务,从而提高程序的执行效率和响应性。

在 C++ 中,从 C++11 开始引入了 <thread> 头文件,提供了对多线程编程的支持。例如,下面的代码展示了如何创建一个简单的线程:

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "This is a thread." << std::endl;
}

int main() {
    std::thread t(threadFunction);
    t.join();
    return 0;
}

在上述代码中,std::thread t(threadFunction); 创建了一个新的线程,并将 threadFunction 作为线程的执行函数。t.join(); 则等待线程执行完毕。

(二)线程同步的必要性

当多个线程同时访问和修改共享资源时,就可能会出现数据竞争(data race)的问题。数据竞争会导致程序出现未定义行为,其结果可能是程序崩溃、产生错误的计算结果等。

例如,考虑以下代码:

#include <iostream>
#include <thread>

int sharedVariable = 0;

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        ++sharedVariable;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

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

    std::cout << "Final value of sharedVariable: " << sharedVariable << std::endl;
    return 0;
}

在这个例子中,sharedVariable 是一个共享的全局变量,两个线程 t1t2 都对其进行递增操作。由于没有进行线程同步,++sharedVariable 这个操作不是原子的,它实际上包含了读取变量值、增加变量值、写回变量值三个步骤。在多线程环境下,可能会出现一个线程读取了变量值,但还没来得及写回,另一个线程又读取了同一个值,导致最终的结果比预期的要小。

二、C++ 全局变量在多线程中的问题本质

(一)全局变量的特性

全局变量在程序的整个生命周期内都存在,并且在程序的任何地方都可以被访问。在单线程程序中,全局变量为数据的共享提供了便利。然而,在多线程程序中,全局变量的共享特性却带来了数据竞争的风险。

当多个线程同时访问和修改全局变量时,如果没有适当的同步机制,就会出现未定义行为。例如,假设一个全局变量 globalValue 被两个线程同时读取和修改:

#include <iostream>
#include <thread>

int globalValue = 0;

void readAndModify() {
    int localCopy = globalValue;
    localCopy += 1;
    globalValue = localCopy;
}

int main() {
    std::thread t1(readAndModify);
    std::thread t2(readAndModify);

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

    std::cout << "Final value of globalValue: " << globalValue << std::endl;
    return 0;
}

在这个代码中,readAndModify 函数读取全局变量 globalValue,对其进行修改,然后写回。由于线程执行的不确定性,可能会出现两个线程同时读取相同的值,然后分别进行修改并写回,最终导致 globalValue 的值只增加了 1,而不是预期的 2。

(二)内存可见性问题

除了数据竞争,多线程中全局变量还存在内存可见性问题。现代处理器为了提高性能,会使用多级缓存(cache)。当一个线程修改了全局变量的值时,这个修改可能只存在于该线程所在处理器核心的缓存中,而其他线程所在处理器核心的缓存中仍然是旧的值。

例如,考虑以下代码:

#include <iostream>
#include <thread>

bool flag = false;
int data = 0;

void writer() {
    data = 42;
    flag = true;
}

void reader() {
    while (!flag);
    std::cout << "Data: " << data << std::endl;
}

int main() {
    std::thread t1(writer);
    std::thread t2(reader);

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

    return 0;
}

在这个例子中,writer 线程先修改 data 的值,然后设置 flagtruereader 线程在 flagtrue 时读取 data 的值。然而,由于内存可见性问题,reader 线程可能永远看不到 flag 的更新,因为 flag 的更新可能还在 writer 线程所在处理器核心的缓存中,没有及时刷新到主内存,导致 reader 线程陷入死循环。

三、解决 C++ 全局变量引用的多线程问题的方法

(一)互斥锁(Mutex)

互斥锁(std::mutex)是 C++ 中最常用的线程同步工具之一。它用于保护共享资源,确保在同一时间只有一个线程能够访问该资源。

以下是使用互斥锁解决前面 sharedVariable 递增问题的代码示例:

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

int sharedVariable = 0;
std::mutex mtx;

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        mtx.lock();
        ++sharedVariable;
        mtx.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

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

    std::cout << "Final value of sharedVariable: " << sharedVariable << std::endl;
    return 0;
}

在上述代码中,mtx.lock(); 用于锁定互斥锁,确保只有一个线程能够进入临界区(++sharedVariable; 这部分代码)。mtx.unlock(); 则释放互斥锁,允许其他线程获取锁并进入临界区。这样就避免了数据竞争问题,保证了 sharedVariable 的正确递增。

(二)读写锁(Read - Write Lock)

读写锁(std::shared_mutex)适用于共享资源读多写少的场景。它允许多个线程同时进行读操作,但在写操作时,会独占资源,防止其他线程进行读或写操作。

以下是一个使用读写锁的示例:

#include <iostream>
#include <thread>
#include <shared_mutex>

int sharedData = 0;
std::shared_mutex dataMutex;

void reader() {
    dataMutex.lock_shared();
    std::cout << "Reader reads: " << sharedData << std::endl;
    dataMutex.unlock_shared();
}

void writer() {
    dataMutex.lock();
    sharedData++;
    std::cout << "Writer writes: " << sharedData << std::endl;
    dataMutex.unlock();
}

int main() {
    std::thread readers[5];
    std::thread writerThread(writer);

    for (int i = 0; i < 5; ++i) {
        readers[i] = std::thread(reader);
    }

    for (int i = 0; i < 5; ++i) {
        readers[i].join();
    }

    writerThread.join();

    return 0;
}

在这个例子中,reader 线程使用 dataMutex.lock_shared(); 来获取共享锁进行读操作,允许多个 reader 线程同时读取 sharedData。而 writer 线程使用 dataMutex.lock(); 获取独占锁进行写操作,防止其他线程在写操作时访问 sharedData

(三)原子操作(Atomic Operations)

C++ 标准库提供了 <atomic> 头文件,用于进行原子操作。原子操作是不可分割的操作,不会被线程调度机制中断。对于一些简单的变量类型,如 intbool 等,可以使用原子类型来避免数据竞争。

以下是使用原子类型解决 sharedVariable 递增问题的代码示例:

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> sharedVariable(0);

void increment() {
    for (int i = 0; i < 1000000; ++i) {
        ++sharedVariable;
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

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

    std::cout << "Final value of sharedVariable: " << sharedVariable.load() << std::endl;
    return 0;
}

在上述代码中,std::atomic<int> sharedVariable(0); 定义了一个原子类型的 sharedVariable++sharedVariable; 操作是原子的,不会出现数据竞争问题。sharedVariable.load() 用于获取原子变量的值。

四、多线程全局变量引用的常见错误及避免方法

(一)死锁问题

死锁是多线程编程中常见的问题之一。当两个或多个线程相互等待对方释放资源时,就会发生死锁。例如,考虑以下代码:

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

std::mutex mutex1;
std::mutex mutex2;

void thread1Function() {
    mutex1.lock();
    std::cout << "Thread 1 locked mutex1" << std::endl;

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    mutex2.lock();
    std::cout << "Thread 1 locked mutex2" << std::endl;

    mutex2.unlock();
    mutex1.unlock();
}

void thread2Function() {
    mutex2.lock();
    std::cout << "Thread 2 locked mutex2" << std::endl;

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    mutex1.lock();
    std::cout << "Thread 2 locked mutex1" << std::endl;

    mutex1.unlock();
    mutex2.unlock();
}

int main() {
    std::thread t1(thread1Function);
    std::thread t2(thread2Function);

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

    return 0;
}

在这个例子中,thread1Function 先锁定 mutex1,然后尝试锁定 mutex2。而 thread2Function 先锁定 mutex2,然后尝试锁定 mutex1。如果 thread1 先锁定 mutex1thread2 先锁定 mutex2,那么两个线程就会相互等待对方释放锁,从而导致死锁。

为了避免死锁,可以采用以下几种方法:

  1. 按顺序加锁:所有线程都按照相同的顺序获取锁。例如,在上述例子中,如果两个线程都先获取 mutex1,再获取 mutex2,就不会发生死锁。
  2. 使用 std::lockstd::lock 函数可以一次性锁定多个互斥锁,并且以一种无死锁的方式进行锁定。例如:
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1;
std::mutex mutex2;

void thread1Function() {
    std::lock(mutex1, mutex2);
    std::cout << "Thread 1 locked mutex1 and mutex2" << std::endl;

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    mutex2.unlock();
    mutex1.unlock();
}

void thread2Function() {
    std::lock(mutex1, mutex2);
    std::cout << "Thread 2 locked mutex1 and mutex2" << std::endl;

    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    mutex2.unlock();
    mutex1.unlock();
}

int main() {
    std::thread t1(thread1Function);
    std::thread t2(thread2Function);

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

    return 0;
}

(二)条件变量使用不当

条件变量(std::condition_variable)用于线程间的同步,通常与互斥锁一起使用。常见的错误是在使用条件变量时没有正确处理谓词(predicate)。

例如,以下代码展示了一个错误的使用方式:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void printId(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    while (!ready) cv.wait(lock);
    std::cout << "thread " << id << '\n';
}

void go() {
    std::unique_lock<std::mutex> lock(mtx);
    ready = true;
    cv.notify_all();
}

int main() {
    std::thread threads[10];
    for (int i = 0; i < 10; ++i) {
        threads[i] = std::thread(printId, i);
    }

    std::cout << "10 threads ready to race...\n";
    go();

    for (auto& th : threads) {
        th.join();
    }

    return 0;
}

在这个例子中,printId 线程在 readyfalse 时等待条件变量 cvgo 函数设置 readytrue 并通知所有等待的线程。然而,如果 go 函数在 printId 线程获取锁之前就设置 readytrue 并通知,那么 printId 线程可能会错过通知,导致死等。

正确的做法是在等待条件变量时使用循环检查谓词,以防止虚假唤醒(spurious wakeup):

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void printId(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });
    std::cout << "thread " << id << '\n';
}

void go() {
    std::unique_lock<std::mutex> lock(mtx);
    ready = true;
    cv.notify_all();
}

int main() {
    std::thread threads[10];
    for (int i = 0; i < 10; ++i) {
        threads[i] = std::thread(printId, i);
    }

    std::cout << "10 threads ready to race...\n";
    go();

    for (auto& th : threads) {
        th.join();
    }

    return 0;
}

在上述代码中,cv.wait(lock, [] { return ready; }); 使用了一个谓词,确保只有当 readytrue 时才会从等待中返回,避免了虚假唤醒导致的问题。

五、性能考虑与优化

(一)锁的粒度

在使用锁来保护全局变量时,锁的粒度对性能有很大影响。锁的粒度是指被锁保护的资源范围。如果锁的粒度过大,即保护的资源过多,会导致线程竞争加剧,从而降低程序的并发性能。例如,如果一个锁保护了一个大的结构体,其中只有一小部分数据会被频繁修改,那么其他线程在访问结构体中未被修改的部分时也需要等待锁,这就造成了不必要的性能开销。

相反,如果锁的粒度过小,虽然可以提高并发性能,但可能会增加锁的管理开销,并且可能会引入更多的死锁风险。因此,需要根据具体的应用场景,合理地选择锁的粒度。

例如,考虑以下代码:

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

struct BigData {
    int data1;
    int data2;
    // 更多数据成员
    std::mutex dataMutex;
};

BigData sharedData;

void modifyData1() {
    std::lock_guard<std::mutex> lock(sharedData.dataMutex);
    sharedData.data1++;
}

void readData2() {
    std::lock_guard<std::mutex> lock(sharedData.dataMutex);
    std::cout << "Data2: " << sharedData.data2 << std::endl;
}

int main() {
    std::thread t1(modifyData1);
    std::thread t2(readData2);

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

    return 0;
}

在这个例子中,BigData 结构体使用一个互斥锁 dataMutex 保护所有数据成员。如果 data1data2 很少同时被访问,那么可以考虑将锁的粒度细化,为 data1data2 分别使用不同的互斥锁,以提高并发性能。

(二)无锁数据结构

在某些情况下,使用无锁数据结构可以避免锁带来的性能开销。无锁数据结构利用原子操作和其他技术,允许多个线程在不使用锁的情况下安全地访问和修改数据。

例如,C++ 标准库中的 std::atomic 类型就可以用于构建简单的无锁数据结构。此外,一些第三方库也提供了更复杂的无锁数据结构,如无锁队列、无锁哈希表等。

以下是一个简单的无锁计数器的示例:

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> counter(0);

void incrementCounter() {
    for (int i = 0; i < 1000000; ++i) {
        counter.fetch_add(1);
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

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

    std::cout << "Final counter value: " << counter.load() << std::endl;
    return 0;
}

在这个例子中,std::atomic<int> counter(0); 定义了一个原子计数器。counter.fetch_add(1); 是一个原子操作,用于递增计数器,不需要使用锁。

(三)线程亲和性

线程亲和性(Thread Affinity)是指将线程绑定到特定的处理器核心上运行。通过设置线程亲和性,可以减少线程在不同处理器核心之间切换带来的开销,从而提高性能。

在 Linux 系统中,可以使用 sched_setaffinity 函数来设置线程亲和性。以下是一个简单的示例:

#include <iostream>
#include <thread>
#include <sched.h>

void threadFunction() {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(0, &cpuset);
    if (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) == -1) {
        std::cerr << "Set affinity failed" << std::endl;
    }
    // 线程执行的任务
}

int main() {
    std::thread t(threadFunction);
    t.join();
    return 0;
}

在上述代码中,CPU_SET(0, &cpuset); 将线程绑定到第 0 个处理器核心上。sched_setaffinity 函数用于设置线程的亲和性。需要注意的是,不同操作系统设置线程亲和性的方法可能不同,在 Windows 系统中,可以使用 SetThreadAffinityMask 函数来实现类似功能。

六、跨平台考虑

在多线程编程中,不同操作系统对多线程的支持和实现方式存在差异。虽然 C++ 标准库提供了跨平台的多线程编程接口,但在实际应用中,仍然可能需要考虑一些跨平台的细节。

(一)线程创建与管理

在 C++11 之前,不同操作系统使用不同的函数来创建和管理线程。例如,在 Windows 系统中使用 CreateThread 函数,而在 Linux 系统中使用 pthread_create 函数。C++11 引入的 <thread> 头文件提供了统一的接口,使得线程的创建和管理更加方便和跨平台。

然而,在某些情况下,可能仍然需要使用操作系统特定的函数来获取更底层的控制。例如,在 Windows 系统中,如果需要设置线程的优先级,可以使用 SetThreadPriority 函数,而在 Linux 系统中,可以使用 pthread_setschedparam 函数。

(二)同步原语的差异

不同操作系统对同步原语的实现和语义也可能存在差异。例如,在 Windows 系统中,CRITICAL_SECTION 是一种轻量级的互斥锁,而在 Linux 系统中,pthread_mutex_t 是常用的互斥锁。虽然 C++ 标准库中的 std::mutex 提供了统一的接口,但在性能和使用场景上,不同操作系统的原生同步原语可能更适合某些特定的应用。

此外,条件变量在不同操作系统上也有一些差异。在 Linux 系统中,pthread_cond_t 是条件变量的实现,而在 Windows 系统中,可以使用 CONDITION_VARIABLE。C++ 标准库的 std::condition_variable 对这些差异进行了封装,但在深入优化或处理复杂同步场景时,可能需要了解底层的操作系统实现。

(三)内存模型

不同操作系统和处理器架构对内存模型的实现也有所不同。这可能会影响到多线程程序中内存可见性和数据竞争的处理。C++ 标准定义了一个内存模型,以确保在不同平台上的一致性,但在某些极端情况下,仍然可能需要考虑平台特定的内存屏障(memory barrier)操作。

例如,在一些处理器架构上,为了确保内存操作的顺序性,可能需要使用特定的指令来插入内存屏障。虽然 C++ 标准库提供了 std::memory_order 枚举来控制原子操作的内存顺序,但在一些底层优化场景下,可能需要直接使用平台特定的内存屏障指令。

七、实际应用案例分析

(一)服务器端编程

在服务器端编程中,经常会遇到多个客户端同时连接到服务器并请求服务的情况。服务器需要处理这些并发请求,并且可能会共享一些全局资源,如数据库连接池、缓存等。

例如,一个简单的 Web 服务器可能会使用全局变量来存储当前在线的用户数量。当一个新用户连接时,增加这个全局变量的值;当用户断开连接时,减少这个值。如果不进行适当的线程同步,可能会导致用户数量统计错误。

以下是一个简化的服务器端代码示例,展示了如何使用互斥锁来保护全局变量:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex userCountMutex;
int onlineUserCount = 0;

void handleClient() {
    std::lock_guard<std::mutex> lock(userCountMutex);
    ++onlineUserCount;
    std::cout << "New user connected. Total online users: " << onlineUserCount << std::endl;

    // 处理客户端请求的代码

    std::lock_guard<std::mutex> unlock(userCountMutex);
    --onlineUserCount;
    std::cout << "User disconnected. Total online users: " << onlineUserCount << std::endl;
}

int main() {
    std::vector<std::thread> clientThreads;
    for (int i = 0; i < 10; ++i) {
        clientThreads.emplace_back(handleClient);
    }

    for (auto& th : clientThreads) {
        th.join();
    }

    return 0;
}

在这个例子中,handleClient 函数模拟处理客户端请求,使用互斥锁 userCountMutex 来保护 onlineUserCount 全局变量,确保其在多线程环境下的正确更新。

(二)并行计算

在并行计算中,多个线程可能会共同处理一个大型数据集,并且可能会共享一些中间结果或全局参数。

例如,在一个矩阵乘法的并行计算中,多个线程分别计算矩阵的不同部分,并且可能会共享一些用于存储结果的全局变量。如果不进行同步,可能会导致结果错误。

以下是一个简单的矩阵乘法并行计算示例,使用原子操作来处理共享结果:

#include <iostream>
#include <thread>
#include <atomic>
#include <vector>

const int matrixSize = 100;
std::atomic<int> resultMatrix[matrixSize][matrixSize];

void multiplyRowColumn(int row, int col, const std::vector<std::vector<int>>& matrixA, const std::vector<std::vector<int>>& matrixB) {
    for (int i = 0; i < matrixSize; ++i) {
        resultMatrix[row][col] += matrixA[row][i] * matrixB[i][col];
    }
}

int main() {
    std::vector<std::vector<int>> matrixA(matrixSize, std::vector<int>(matrixSize, 1));
    std::vector<std::vector<int>> matrixB(matrixSize, std::vector<int>(matrixSize, 2));

    std::vector<std::thread> threads;
    for (int i = 0; i < matrixSize; ++i) {
        for (int j = 0; j < matrixSize; ++j) {
            threads.emplace_back(multiplyRowColumn, i, j, std::ref(matrixA), std::ref(matrixB));
        }
    }

    for (auto& th : threads) {
        th.join();
    }

    // 输出结果矩阵
    for (int i = 0; i < matrixSize; ++i) {
        for (int j = 0; j < matrixSize; ++j) {
            std::cout << resultMatrix[i][j].load() << " ";
        }
        std::cout << std::endl;
    }

    return 0;
}

在这个例子中,resultMatrix 是一个共享的全局变量,使用原子类型来确保在多线程计算矩阵乘法时的正确性。每个线程负责计算结果矩阵的一个元素,通过原子操作 resultMatrix[row][col] += matrixA[row][i] * matrixB[i][col]; 来更新结果。

八、总结与展望

C++ 全局变量引用的多线程问题是多线程编程中一个重要且复杂的领域。通过深入理解多线程编程的基础知识、全局变量在多线程中的问题本质,以及掌握各种解决方法和优化技巧,开发者可以编写出高效、稳定的多线程程序。

在未来,随着硬件技术的不断发展,多核处理器的性能将进一步提升,多线程编程的需求也将日益增长。C++ 标准库也将不断完善对多线程编程的支持,提供更多高效、易用的工具和接口。同时,新的编程模型和架构可能会涌现,为解决多线程问题带来新的思路和方法。开发者需要持续关注技术发展动态,不断学习和实践,以应对日益复杂的多线程编程挑战。