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

C++线程崩溃的常见原因分析

2023-09-283.7k 阅读

C++ 线程崩溃的常见原因分析

未初始化或非法的线程对象

在 C++ 中使用线程时,确保线程对象正确初始化是至关重要的。如果线程对象未初始化就尝试启动线程,或者使用非法的线程对象进行操作,极有可能导致程序崩溃。

例如,以下代码就存在未初始化线程对象的问题:

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Thread is running" << std::endl;
}

int main() {
    std::thread myThread;
    // 这里 myThread 未初始化
    myThread = std::thread(threadFunction);
    myThread.join();
    return 0;
}

在上面的代码中,首先声明了 myThread 但并未初始化。之后虽然通过赋值的方式进行了初始化,但在某些编译器环境下,这种先声明后赋值的方式可能导致不可预测的行为,包括程序崩溃。正确的做法是在声明时就进行初始化:

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Thread is running" << std::endl;
}

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

另外,如果尝试使用已经 join()detach() 的线程对象再次进行操作,也会导致未定义行为。比如:

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Thread is running" << std::endl;
}

int main() {
    std::thread myThread(threadFunction);
    myThread.join();
    // 尝试再次对已 join 的线程进行操作
    myThread = std::thread(threadFunction);
    return 0;
}

上述代码在第二次对 myThread 进行赋值时,由于 myThread 已经执行过 join(),此时再次赋值会导致未定义行为,很可能引发程序崩溃。

内存访问违规

访问已释放的内存

在线程中访问已经释放的内存是一个常见的导致线程崩溃的原因。当多个线程共享内存资源,而其中一个线程在其他线程仍在使用该内存时将其释放,就会出现这种情况。

例如:

#include <iostream>
#include <thread>
#include <memory>

int* sharedData;

void thread1() {
    sharedData = new int(42);
    std::cout << "Thread 1: Data set to " << *sharedData << std::endl;
}

void thread2() {
    // 等待 thread1 设置数据
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Thread 2: Data is " << *sharedData << std::endl;
    delete sharedData;
    sharedData = nullptr;
}

void thread3() {
    // 等待 thread2 可能释放数据
    std::this_thread::sleep_for(std::chrono::seconds(2));
    if (sharedData != nullptr) {
        std::cout << "Thread 3: Data is " << *sharedData << std::endl;
    } else {
        std::cout << "Thread 3: Data has been released" << std::endl;
    }
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);
    std::thread t3(thread3);

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

    return 0;
}

在这个例子中,thread1 分配内存并设置数据,thread2 等待 thread1 完成后读取数据并释放内存,thread3 再等待一段时间后尝试读取数据。如果 thread3thread2 释放内存后仍尝试访问 sharedData,就会导致内存访问违规,因为此时 sharedData 指向的内存已经被释放。

为了避免这种情况,可以使用智能指针来管理内存。例如:

#include <iostream>
#include <thread>
#include <memory>

std::shared_ptr<int> sharedData;

void thread1() {
    sharedData = std::make_shared<int>(42);
    std::cout << "Thread 1: Data set to " << *sharedData << std::endl;
}

void thread2() {
    // 等待 thread1 设置数据
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Thread 2: Data is " << *sharedData << std::endl;
    // 这里无需手动释放,shared_ptr 会自动管理
}

void thread3() {
    // 等待 thread2 可能释放数据
    std::this_thread::sleep_for(std::chrono::seconds(2));
    if (sharedData != nullptr) {
        std::cout << "Thread 3: Data is " << *sharedData << std::endl;
    } else {
        std::cout << "Thread 3: Data has been released" << std::endl;
    }
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);
    std::thread t3(thread3);

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

    return 0;
}

访问越界的内存

在多线程环境下,访问越界的内存同样可能引发线程崩溃。比如,当多个线程同时操作数组或容器时,如果没有正确的边界检查,就可能出现越界访问。

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

std::vector<int> sharedVector(10);

void thread1() {
    for (int i = 0; i < 15; ++i) {
        sharedVector[i] = i;
    }
}

void thread2() {
    for (int i = 0; i < sharedVector.size(); ++i) {
        std::cout << "Thread 2: Element " << i << " is " << sharedVector[i] << std::endl;
    }
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

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

    return 0;
}

在上述代码中,thread1 尝试向 sharedVector 中写入超出其容量的数据,这会导致越界访问。thread2 在读取数据时可能会因为 thread1 的越界写入而读取到非法数据,甚至导致程序崩溃。

为了防止这种情况,需要在操作数组或容器时进行严格的边界检查。例如:

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

std::vector<int> sharedVector(10);

void thread1() {
    for (int i = 0; i < sharedVector.size(); ++i) {
        sharedVector[i] = i;
    }
}

void thread2() {
    for (int i = 0; i < sharedVector.size(); ++i) {
        std::cout << "Thread 2: Element " << i << " is " << sharedVector[i] << std::endl;
    }
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

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

    return 0;
}

竞争条件

竞争条件是多线程编程中常见的问题,当多个线程同时访问和修改共享资源,且操作顺序依赖于线程调度时,就会出现竞争条件。这可能导致数据不一致,进而引发程序崩溃。

对共享变量的无保护访问

考虑以下代码:

#include <iostream>
#include <thread>

int sharedVariable = 0;

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

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

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

    std::cout << "Expected value: 20000, Actual value: " << sharedVariable << std::endl;

    return 0;
}

在上述代码中,increment 函数尝试对 sharedVariable 进行 10000 次递增操作。理论上,两个线程执行完毕后 sharedVariable 的值应该是 20000。但由于两个线程同时访问和修改 sharedVariable,没有任何同步机制,就会出现竞争条件。在实际运行中,sharedVariable 的值往往小于 20000。

为了解决这个问题,可以使用互斥锁(std::mutex)来保护共享变量:

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

int sharedVariable = 0;
std::mutex sharedMutex;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard<std::mutex> lock(sharedMutex);
        ++sharedVariable;
    }
}

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

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

    std::cout << "Expected value: 20000, Actual value: " << sharedVariable << std::endl;

    return 0;
}

在这个改进的代码中,std::lock_guard 在构造时自动锁定互斥锁 sharedMutex,在析构时自动解锁,从而确保在任何时刻只有一个线程可以访问 sharedVariable,避免了竞争条件。

双重检查锁定的错误使用

双重检查锁定(Double - Checked Locking)是一种常见的优化同步操作的方式,但如果使用不当,仍然会导致竞争条件。

以下是一个错误使用双重检查锁定的示例:

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

class Singleton {
public:
    static Singleton* getInstance() {
        if (instance == nullptr) {
            std::lock_guard<std::mutex> lock(mutex);
            if (instance == nullptr) {
                instance = new Singleton();
            }
        }
        return instance;
    }

private:
    Singleton() {}
    ~Singleton() {}
    static Singleton* instance;
    static std::mutex mutex;
};

Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

void threadFunction() {
    Singleton* singleton = Singleton::getInstance();
    std::cout << "Thread got singleton instance: " << singleton << std::endl;
}

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

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

    return 0;
}

在上述代码中,表面上看通过双重检查锁定,只有在 instancenullptr 时才会加锁创建实例,似乎可以提高效率。然而,由于编译器优化和指令重排序的原因,在多线程环境下,可能会出现一个线程看到 instance 不为 nullptr,但实际上 instance 尚未完全构造的情况,这就会导致程序崩溃。

正确的实现方式可以使用 C++11 中的局部静态变量:

#include <iostream>
#include <thread>

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }

private:
    Singleton() {}
    ~Singleton() {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

void threadFunction() {
    Singleton& singleton = Singleton::getInstance();
    std::cout << "Thread got singleton instance: " << &singleton << std::endl;
}

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

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

    return 0;
}

C++11 保证了局部静态变量的初始化是线程安全的,这种方式避免了双重检查锁定的问题。

死锁

死锁是多线程编程中另一个严重的问题,当两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行时,就会发生死锁。

简单的死锁示例

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

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

void thread1() {
    std::lock_guard<std::mutex> lock1(mutex1);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::lock_guard<std::mutex> lock2(mutex2);
    std::cout << "Thread 1 got both locks" << std::endl;
}

void thread2() {
    std::lock_guard<std::mutex> lock2(mutex2);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::lock_guard<std::mutex> lock1(mutex1);
    std::cout << "Thread 2 got both locks" << std::endl;
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

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

    return 0;
}

在上述代码中,thread1 首先锁定 mutex1,然后等待 1 秒后尝试锁定 mutex2thread2 则首先锁定 mutex2,等待 1 秒后尝试锁定 mutex1。由于两个线程相互等待对方释放锁,就会导致死锁。

死锁的避免方法

  1. 按顺序加锁:所有线程按照相同的顺序获取锁。例如,修改上述代码如下:
#include <iostream>
#include <thread>
#include <mutex>

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

void thread1() {
    std::lock_guard<std::mutex> lock1(mutex1);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::lock_guard<std::mutex> lock2(mutex2);
    std::cout << "Thread 1 got both locks" << std::endl;
}

void thread2() {
    std::lock_guard<std::mutex> lock1(mutex1);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::lock_guard<std::mutex> lock2(mutex2);
    std::cout << "Thread 2 got both locks" << std::endl;
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

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

    return 0;
}

在这个改进的代码中,thread1thread2 都先获取 mutex1,再获取 mutex2,避免了死锁。

  1. 使用 std::lockstd::lock 函数可以一次性锁定多个互斥锁,并且保证不会发生死锁。
#include <iostream>
#include <thread>
#include <mutex>

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

void thread1() {
    std::lock(mutex1, mutex2);
    std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
    std::cout << "Thread 1 got both locks" << std::endl;
}

void thread2() {
    std::lock(mutex1, mutex2);
    std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
    std::cout << "Thread 2 got both locks" << std::endl;
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

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

    return 0;
}

std::lock 会尝试一次性锁定所有给定的互斥锁,如果无法全部锁定,它会自动解锁已经锁定的互斥锁,从而避免死锁。

线程函数异常处理不当

当线程函数抛出异常而没有在合适的地方捕获时,可能会导致程序崩溃。

未捕获的异常示例

#include <iostream>
#include <thread>

void threadFunction() {
    throw std::runtime_error("An error occurred in thread");
}

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

在上述代码中,threadFunction 抛出了一个 std::runtime_error 异常,但没有在任何地方捕获。当 myThread.join() 执行时,由于未捕获的异常,程序很可能会崩溃。

正确的异常处理

为了避免这种情况,需要在适当的地方捕获异常。可以在 try - catch 块中启动线程,如下所示:

#include <iostream>
#include <thread>

void threadFunction() {
    throw std::runtime_error("An error occurred in thread");
}

int main() {
    try {
        std::thread myThread(threadFunction);
        myThread.join();
    } catch (const std::exception& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }
    return 0;
}

在这个改进的代码中,try - catch 块捕获了线程函数抛出的异常,并进行了相应的处理,从而避免了程序崩溃。

线程局部存储(TLS)相关问题

线程局部存储允许每个线程拥有自己独立的变量副本。然而,如果使用不当,也可能导致问题。

不正确的 TLS 变量访问

#include <iostream>
#include <thread>
#include <thread_local>

thread_local int threadLocalVariable = 0;

void threadFunction() {
    ++threadLocalVariable;
    std::cout << "Thread: " << std::this_thread::get_id() << " has value: " << threadLocalVariable << std::endl;
}

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

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

    return 0;
}

虽然上述代码本身看起来没有问题,但如果在某些复杂的场景下,例如在动态链接库(DLL)中使用线程局部存储,可能会出现变量初始化和访问的问题。特别是在不同的编译环境或运行时库版本下,可能会导致未定义行为。

为了确保正确性,需要仔细了解目标平台和运行时库对线程局部存储的支持和特性。例如,在一些情况下,可能需要显式地指定 TLS 变量的存储类型和初始化方式,以避免潜在的问题。

资源泄漏

线程资源泄漏

当线程创建了一些资源(如文件句柄、网络连接等),但在退出时没有正确释放这些资源,就会发生资源泄漏。这可能会导致系统资源逐渐耗尽,最终影响整个程序的稳定性,甚至导致程序崩溃。

例如,以下代码模拟了一个线程打开文件但未关闭的情况:

#include <iostream>
#include <thread>
#include <fstream>

void threadFunction() {
    std::ofstream file("test.txt");
    if (file.is_open()) {
        file << "Some data" << std::endl;
        // 这里没有关闭文件
    }
}

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

在上述代码中,threadFunction 打开了一个文件并写入数据,但没有调用 file.close()。虽然在程序结束时,操作系统可能会自动关闭文件,但在多线程频繁创建和销毁文件的场景下,这可能会导致文件句柄资源的浪费,最终可能影响系统性能甚至导致程序崩溃。

为了避免这种情况,需要确保在离开线程函数之前正确释放所有资源。可以使用 RAII(Resource Acquisition Is Initialization)原则,例如:

#include <iostream>
#include <thread>
#include <fstream>

class FileGuard {
public:
    FileGuard(const std::string& filename) : file(filename) {}
    ~FileGuard() {
        if (file.is_open()) {
            file.close();
        }
    }

private:
    std::ofstream file;
};

void threadFunction() {
    FileGuard fileGuard("test.txt");
    std::ofstream& file = fileGuard.file;
    if (file.is_open()) {
        file << "Some data" << std::endl;
    }
}

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

在这个改进的代码中,FileGuard 类使用 RAII 原则,在构造时打开文件,在析构时关闭文件,确保文件资源在离开 threadFunction 时被正确释放。

线程句柄资源泄漏

在某些操作系统特定的多线程实现中,创建线程可能会返回一个线程句柄。如果没有正确管理这些线程句柄,也会导致资源泄漏。

例如,在 Windows 操作系统中使用 CreateThread 创建线程:

#include <windows.h>
#include <iostream>

DWORD WINAPI threadFunction(LPVOID lpParam) {
    std::cout << "Thread is running" << std::endl;
    return 0;
}

int main() {
    HANDLE hThread = CreateThread(NULL, 0, threadFunction, NULL, 0, NULL);
    if (hThread != NULL) {
        // 这里没有关闭线程句柄
        WaitForSingleObject(hThread, INFINITE);
    }
    return 0;
}

在上述代码中,CreateThread 返回一个线程句柄 hThread,但没有调用 CloseHandle(hThread) 来关闭句柄。随着程序中不断创建线程而不关闭句柄,系统的内核对象句柄资源会逐渐耗尽,可能导致系统不稳定甚至程序崩溃。

正确的做法是在使用完线程句柄后及时关闭:

#include <windows.h>
#include <iostream>

DWORD WINAPI threadFunction(LPVOID lpParam) {
    std::cout << "Thread is running" << std::endl;
    return 0;
}

int main() {
    HANDLE hThread = CreateThread(NULL, 0, threadFunction, NULL, 0, NULL);
    if (hThread != NULL) {
        WaitForSingleObject(hThread, INFINITE);
        CloseHandle(hThread);
    }
    return 0;
}

线程调度问题

优先级反转

优先级反转是指高优先级线程被低优先级线程阻塞,导致高优先级线程无法及时执行的现象。这可能会影响程序的实时性,在一些对时间敏感的应用中,可能会导致程序出现异常甚至崩溃。

例如,假设有三个线程:高优先级线程 H、中优先级线程 M 和低优先级线程 LL 持有一个资源,H 需要这个资源,但在 L 释放资源之前,M 抢占了 L 的执行权,导致 H 无法获取资源而被阻塞,从而出现优先级反转。

在 C++ 标准库中,虽然没有直接提供控制线程优先级的接口,但在一些操作系统特定的实现中可以设置线程优先级。例如,在 Linux 系统中,可以使用 pthread_setschedparam 函数来设置线程的调度策略和优先级:

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

void* threadFunction(void* arg) {
    std::cout << "Thread is running" << std::endl;
    return nullptr;
}

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, threadFunction, NULL);

    struct sched_param param;
    param.sched_priority = sched_get_priority_max(SCHED_FIFO);
    pthread_setschedparam(thread, SCHED_FIFO, &param);

    pthread_join(thread, NULL);

    return 0;
}

在上述代码中,通过 pthread_setschedparam 将新创建的线程设置为最高优先级的 SCHED_FIFO 调度策略。但在实际应用中,需要谨慎设置线程优先级,避免出现优先级反转等问题。

线程饥饿

线程饥饿是指一个线程由于其他线程持续占用 CPU 资源,导致该线程长时间无法获得执行机会。这可能会使该线程对应的任务无法及时完成,影响整个程序的功能。

例如,在一个多线程程序中,如果有一些计算密集型的高优先级线程持续占用 CPU,而低优先级的 I/O 操作线程可能会长时间无法执行,导致数据无法及时读取或写入,进而影响程序的正常运行。

为了避免线程饥饿,可以采用公平调度算法,确保每个线程都有机会获得 CPU 时间。一些操作系统提供了相关的调度策略来支持公平调度,例如 Linux 的完全公平调度器(CFS)。在 C++ 编程中,虽然标准库没有直接提供控制公平调度的接口,但可以通过合理设计线程任务和资源分配,尽量避免出现某类线程长期得不到执行机会的情况。例如,可以将计算密集型任务适当拆分,或者为不同类型的线程分配不同的 CPU 核心等方式来缓解线程饥饿问题。

总结

在 C++ 多线程编程中,线程崩溃可能由多种原因引起,包括未初始化或非法的线程对象、内存访问违规、竞争条件、死锁、线程函数异常处理不当、线程局部存储问题、资源泄漏以及线程调度问题等。通过深入理解这些问题的本质,并采用正确的编程实践,如使用智能指针管理内存、使用同步机制避免竞争条件和死锁、正确处理线程函数异常、合理使用线程局部存储、及时释放资源以及关注线程调度等,可以有效地减少线程崩溃的发生,提高多线程程序的稳定性和可靠性。在实际开发中,还需要结合具体的应用场景和目标平台,进行全面的测试和优化,以确保多线程程序能够高效、稳定地运行。