CppCon 2018 学习:What do you mean “thread-safe“
什么是“线程安全”?
“线程安全”指的是一个函数、方法或代码块能够在多个线程同时执行时,不会出现意外的交互或破坏共享数据,能够安全地运行。
POSIX 对线程安全的定义很清楚:
“一个线程安全的函数可以在多个线程中被安全地并发调用,无论是与同一个函数的其他调用,还是与其他线程安全函数的调用。”
这意味着,当多个线程同时调用一个函数时,这个函数应该能够正常工作,而不会导致数据破坏或未定义的行为。
线程安全的例子:
- 非线程安全的例子:
int in[100], out[100]; void thread1() {memcpy(&out, &in, sizeof(in)); } void thread2() {memcpy(&out, &in, sizeof(in)); }
- 在这里,
thread1
和thread2
都在并发访问同一个out
数组。 - 如果这两个线程同时调用
memcpy
,就会发生 数据竞争。即一个线程在将数据复制到out
时,另一个线程可能正在修改它,导致不可预测的结果。 - 这个例子 不是线程安全的,因为
out
数组是共享的,并且没有同步机制来防止多个线程同时访问它。
- 在这里,
- 线程安全的例子:
void thread1() {int out[100];memcpy(&out, &in, sizeof(in)); } void thread2() {int out[100];memcpy(&out, &in, sizeof(in)); }
- 在这种情况下,每个线程都使用自己独立的
out
数组。 - 由于每个线程都有自己的
out
数组,因此不会发生线程间的冲突。 - 这个代码 是线程安全的,因为每个线程都有自己的内存空间,不会互相干扰。
- 在这种情况下,每个线程都使用自己独立的
我们要“安全”避免什么?
在多线程的上下文中,提到“安全”主要是为了避免 竞态条件(race condition)。
什么是 竞态条件?
竞态条件是指当系统的行为依赖于事件的顺序或时机,而这个顺序或时机是无法控制的情况下发生的情况。在多线程程序中,当两个或更多的线程同时访问共享资源(如内存或变量),并且至少有一个线程修改了这个资源,就可能发生竞态条件。
竞态条件的例子:
- 线程 A 和 线程 B 都访问一个共享的计数器。
- 线程 A 读取计数器的值并将其加 1。
- 线程 B 也读取了相同的计数器值(在线程 A 写入更新的值之前),然后将其加 1。
- 最终的计数器值可能没有反映出两个线程的增量,因为两个线程在更新之前都读取了相同的初始值,导致 更新丢失 或 错误的结果。
为什么线程安全很重要?
线程安全确保了在多线程环境中,数据的一致性不会被破坏,并且避免了未定义的行为。如果没有适当的线程同步,多个线程可能会相互干扰,导致 数据损坏、不预期的行为,甚至 崩溃。
总结:
- 线程安全的代码确保多个线程可以并发执行而不会发生不安全的交互。
- 它通过在共享数据访问上进行有效的同步管理,避免了竞态条件。
- 通常,使用 互斥锁、锁 或 原子操作 等同步机制来保证代码线程安全。
如果你想要更深入的了解如何让代码线程安全,或者有任何问题,欢迎随时提问!
我们要“安全”避免什么?—— 数据竞争(Data Races)
在多线程编程中,数据竞争(data race)是一个常见且严重的问题,它通常会导致不可预测的行为、错误的结果,甚至是程序崩溃。
什么是数据竞争?
根据 C++ 标准,一个程序中如果存在数据竞争,那么它包含了两个潜在的并发冲突操作,其中至少有一个操作是 非原子操作。
更具体地说:
数据竞争是指两个表达式的评估存在冲突:
- 其中一个表达式修改了某个内存位置
- 另一个表达式在同一时间读取或修改了同一个内存位置。
数据竞争的例子:
- 修改共享变量的例子:
int i = 0; void thread1() {++i; // 增加 i 的值 } void thread2() {std::cout << i; // 输出 i 的值 }
- 问题:
thread1
在增量操作i
时,i
的值可能会受到thread2
读取i
时的影响。- 如果
thread1
和thread2
同时执行,i
的值可能会因为这两者之间的冲突而变得不确定(例如,thread1
修改了i
,但thread2
可能读取了它的旧值,或者i
的增量操作没有被正确执行)。
- 数据竞争的原因:
i
是共享资源,并且没有同步机制(例如,锁、原子操作等)来保护对i
的并发访问。
- 解决方案:
- 使用 原子操作(如
std::atomic
)来确保对i
的访问是原子性的。 - 或者使用互斥锁(
std::mutex
)来同步对i
的访问。
- 使用 原子操作(如
- 问题:
- 修改字符串的例子:
std::string s = ""; void thread1() {s.append("foo"); // 向字符串中添加 "foo" } void thread2() {std::cout << s; // 输出字符串 s }
- 问题:
thread1
修改了字符串s
,而thread2
同时尝试读取s
。- 如果这两个线程同时执行,
s
可能在thread1
修改时发生冲突,导致 字符串内容不一致 或者 程序崩溃。
- 数据竞争的原因:
s
是共享资源,且没有任何同步机制来防止线程间的并发访问。
- 解决方案:
- 使用
std::mutex
来同步对s
的访问,确保只有一个线程可以修改或读取s
。 - 或者考虑使用线程安全的数据结构(例如,
std::atomic<std::string>
,虽然这在 C++ 标准库中并没有直接支持)。
- 使用
- 问题:
总结:
数据竞争是指两个或多个线程在并发执行时,至少一个线程对共享数据的访问是非原子的,且没有适当的同步机制来确保数据一致性。数据竞争会导致不确定的行为和错误的结果,因此我们需要使用锁、原子操作等同步机制来避免数据竞争。
- 避免数据竞争的关键点:
- 确保对共享资源的访问是原子的。
- 使用合适的同步机制(如
std::mutex
、std::atomic
)。 - 尽量避免多个线程同时修改同一共享资源。
通过这些措施,可以确保代码在并发执行时的正确性和可靠性。
我们要“安全”避免什么?—— API 竞争(API Races)
除了 数据竞争,程序中的另一个常见并发问题是 API 竞争。API 竞争是指在同一个对象上执行并发操作时,该对象的 API 合同(或约定)并不允许这些操作并发执行。
什么是 API 竞争(API Race)?
API 竞争发生在一个程序执行两个并发操作,这两个操作在同一个对象上进行,但该对象的 API 并没有保证这些操作是安全的,或者没有提供适当的同步机制。
简而言之,API 竞争是指多个线程或操作同时在同一个对象上执行时,违反了该对象的使用规则或约定,可能会导致不一致或错误的结果。
API 竞争的例子:
std::string s = "";
void thread1() {s.append("foo"); // 向字符串 s 中添加 "foo"
}
void thread2() {std::cout << s; // 输出字符串 s
}
问题分析:
- 在这个例子中,
s.append("foo")
和std::cout << s
是两个操作,分别发生在不同的线程中。 - 虽然
std::string
类本身是可以用于多个线程,但在并发访问时,std::string
的 API 并没有保证这些操作是线程安全的。thread1
正在修改字符串s
。thread2
正在读取字符串s
。
- 这些操作如果同时执行,可能会导致API 竞争,因为
std::string
没有内建机制来同步并发访问。这样会导致以下问题:- 字符串的修改和读取之间可能会发生冲突,导致读取到不一致的值。
- 在某些情况下,可能会发生内存损坏或者崩溃。
API 竞争的原因:
std::string
的 API 并没有保证多线程环境下并发修改或读取的安全性。- 没有同步机制来保证
append
和<<
操作不会互相干扰。
解决方案:
- 使用互斥锁(mutex):使用
std::mutex
来保证在某个时刻,只有一个线程能够访问和修改s
。std::mutex mtx; std::string s = ""; void thread1() {std::lock_guard<std::mutex> lock(mtx);s.append("foo"); } void thread2() {std::lock_guard<std::mutex> lock(mtx);std::cout << s; }
- 使用线程安全的类或 API:如果需要多线程操作共享数据,选择专门设计为线程安全的类,或者自己实现相应的同步机制。
总结:
API 竞争是指在并发执行时,程序操作的对象的 API 合同并没有明确保障多个操作能够安全地并发执行。为了避免 API 竞争,我们需要确保:
- API 的约定:明确使用对象时,它的 API 是否支持并发操作,或者需要采取额外的同步措施。
- 同步机制:通过互斥锁、条件变量等同步工具,确保在多线程环境下共享资源的安全访问。
通过合理的同步机制和正确理解 API 合同,可以有效避免 API 竞争,提高程序的并发安全性。
识别 API 竞争(API Races)
API 竞争发生在多个线程同时操作同一个对象时,而该对象的 API 合同并不支持这种并发操作。识别 API 竞争是确保多线程程序安全的关键部分。下面我们来通过几个例子详细探讨如何识别 API 竞争。
例 1: API 竞争示例 — 在同一对象上调用不同方法
Widget shared_widget;
void thread1() {// 线程1调用 shared_widget 的 foo() 方法shared_widget.foo();
}
void thread2() {// 线程2调用 shared_widget 的 bar() 方法shared_widget.bar();
}
分析:
- 问题:
shared_widget
对象被多个线程同时操作,foo()
和bar()
方法可能对同一个数据进行修改。由于Widget
类的 API 没有保证这两个方法的线程安全,这可能导致数据竞争(Data Race),比如两个线程同时修改shared_widget
内部的成员变量,从而导致数据不一致。 - 解决方案:
- 确保
foo()
和bar()
方法是线程安全的。 - 使用互斥锁(
std::mutex
)保护对shared_widget
对象的访问,确保一次只有一个线程能够调用这两个方法。
- 确保
例 2: API 竞争示例 — 通过不同的函数调用同一对象
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget); // 线程1调用 t.foo(shared_widget)
}
void thread2() {Whatever w;w.bar(shared_widget); // 线程2调用 w.bar(shared_widget)
}
分析:
- 问题:
shared_widget
被两个线程分别传递给Thingy
和Whatever
类型的对象,并通过它们调用方法。这种情形依然可能导致 API 竞争,尤其是在foo()
和bar()
方法内部执行对shared_widget
的修改操作时。 - 解决方案:
- 让
Thingy::foo()
和Whatever::bar()
方法同步访问shared_widget
,通过互斥锁等同步机制确保同一时刻只有一个线程能访问该对象。 - 如果方法内部没有修改
shared_widget
,那么可以认为是线程兼容的,但仍需要确保没有其他竞争条件。
- 让
例 3: 线程安全的设计 — 使用互斥锁避免竞争条件
// 线程安全的 JobRunner 类
class JobRunner {JobSet running_;JobSet done_;std::mutex m_; // 用于同步的互斥锁void OnJobDone(Job* job) {m_.lock();running_.erase(job); // 在互斥锁保护下操作 running_ 集合done_.insert(job); // 在互斥锁保护下操作 done_ 集合m_.unlock();}
};
// 线程安全的 JobSet 类
class JobSet {std::set<Job*> jobs_;std::mutex m_; // 用于同步的互斥锁void erase(Job* job) {m_.lock();jobs_.erase(job); // 线程安全地操作 jobs_ 集合m_.unlock();}
};
分析:
- 问题解决:在这个设计中,
JobRunner
和JobSet
类通过使用std::mutex
来同步对共享数据的访问,确保了并发操作的安全性。这样,多个线程可以安全地同时调用OnJobDone()
和erase()
方法,而不会引起竞争条件。
例 4: 数据类型的线程安全
int shared_int;
void thread1() {Thingy t;t.foo(shared_int); // 线程1调用 foo() 方法
}
void thread2() {Whatever w;w.bar(shared_int); // 线程2调用 bar() 方法
}
void Thingy::foo(int i) {// 对 shared_int 进行修改
}
void Whatever::bar(const int& i) {// 读取 shared_int
}
分析:
- 问题:如果
Thingy::foo
和Whatever::bar
方法都对shared_int
进行修改,或者至少是并发读取,它们之间就可能会发生 API 竞争,尤其在shared_int
没有进行同步保护的情况下。 - 解决方案:
- 对
shared_int
进行同步保护,确保同一时刻只有一个线程能够修改或读取它。 - 可以使用互斥锁来保护对
shared_int
的访问,或者使用std::atomic
来确保并发操作时的原子性。
- 对
例 5: 线程兼容的对象
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget);
}
void Thingy::foo(const Widget& widget) {// 对 widget 进行只读操作,不修改 shared_widget
}
void thread2() {Whatever w;w.bar(shared_widget);
}
void Whatever::bar(const Widget& widget) {// 对 widget 进行只读操作,不修改 shared_widget
}
分析:
- 问题:如果
shared_widget
是一个线程兼容的类型,并且在foo()
和bar()
方法中仅进行读取操作,而不进行修改,则不会发生 API 竞争。因为此时没有线程同时修改shared_widget
,因此不会产生并发修改的问题。 - 解决方案:确保在多线程环境下,只读访问共享对象,而不进行修改。这样,即使多个线程同时访问该对象,也不会引发竞争条件。
总结:
- API 竞争的核心问题:当多个线程在同一时刻访问同一对象,而该对象的 API 合同不支持并发操作时,就会产生 API 竞争。
- 如何避免 API 竞争:
- 使用 互斥锁(
std::mutex
)来同步对共享对象的访问。 - 如果操作是只读的,并且对象类型支持线程兼容(如
std::atomic
),则可以避免竞争。 - 设计线程安全的 API 或类型,确保在多线程环境下可以安全并发执行。
- 使用 互斥锁(
- 识别 API 竞争:
- 如果对象类型不是线程安全的,或者操作没有同步机制,便会发生 API 竞争。
- 如果对象的操作不符合 API 合同(例如不保证线程安全),则需要加锁或其他同步措施。
通过正确的同步和线程安全设计,可以有效避免 API 竞争,保证多线程程序的安全性和稳定性。
识别 API 竞争(API Races)
API 竞争指的是多个线程在同一时刻并发访问同一对象的情况,导致不可预期的行为,尤其当该对象的 API 合同不支持并发时。正确识别并避免 API 竞争是确保多线程程序正确性和稳定性的关键。以下是通过几个例子进一步理解 API 竞争及其避免方法。
例 1: 线程安全的 LazyStringView
class LazyStringView {const char* data_;mutable std::optional<size_t> size_;mutable std::mutex mu_;
public:size_t size() const {std::scoped_lock lock(mu_); // 通过 scoped_lock 确保线程安全if (!size_) {size_ = strlen(data_);}return *size_;}
};
分析:
- 问题:
LazyStringView
类的size()
方法需要访问size_
和data_
成员变量,这在多线程环境下是潜在的并发问题源。为了确保线程安全,在size()
方法中使用了std::mutex
来加锁,确保每次只有一个线程能修改size_
。 - 解决方案:通过
std::scoped_lock
确保在访问size_
和data_
时是互斥的。这样可以确保即使多个线程同时调用size()
,也不会发生竞争条件。
例 2: 竞争条件示例 — 不同线程调用不同方法
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget); // 线程1调用 foo(shared_widget)
}
void thread2() {Whatever w;w.bar(shared_widget); // 线程2调用 bar(shared_widget)
}
分析:
- 问题:在这个例子中,
shared_widget
是一个共享对象,多个线程同时调用不同的方法可能导致竞争条件。假如foo()
和bar()
方法都操作了shared_widget
,且这些方法没有进行适当的同步,那么就会发生数据竞争。 - 解决方案:可以使用互斥锁(
std::mutex
)对对shared_widget
的访问进行保护,确保同一时刻只有一个线程能够操作它。
例 3: 不同线程调用相同对象的不同方法
Widget shared_widget;
void thread1() {Thingy t;t.foo(shared_widget);
}
void Thingy::foo(const Widget& widget) {// 对 widget 的操作
}
void thread2() {Whatever w;w.bar(shared_widget);
}
void Whatever::bar(const Widget& widget) {// 对 widget 的操作
}
分析:
- 问题:
shared_widget
可能在两个不同线程中同时被传递给foo()
和bar()
方法。如果这两个方法都试图访问并修改shared_widget
,且没有适当的同步机制,就会发生竞争条件。 - 解决方案:确保
foo()
和bar()
方法在访问shared_widget
时是线程安全的。可以使用互斥锁来同步访问,或者将Widget
设计为线程安全类型。
例 4: 线程不安全的函数调用
void Thingy::foo(const Widget&) {static int counter = 0;counter++; // 修改静态变量,可能导致数据竞争
}
void Whatever::bar(const Widget&) {static int counter = 0;counter++; // 修改静态变量,可能导致数据竞争
}
分析:
- 问题:
foo()
和bar()
方法都在修改静态变量counter
,如果两个线程同时调用这两个方法,且它们都试图修改counter
,就会发生数据竞争。 - 解决方案:将静态变量
counter
设计为线程安全的,或者使用互斥锁保护对counter
的访问,避免数据竞争。
线程安全与线程兼容类型
1. 线程安全的类型:
- 如果对象的类型本身是线程安全的(例如使用
std::mutex
等机制进行同步),则该对象在多线程环境下是安全的,不能成为 API 竞争的源头。
2. 线程兼容的类型:
- 如果对象类型是线程兼容的(例如,只进行只读操作,不会改变内部状态),并且没有被并发修改,那么该对象在并发访问下是安全的。
3. 非线程安全类型:
- 如果一个对象的类型在多线程环境下没有设计为线程安全,且多个线程同时对它进行修改操作,那么就会发生 API 竞争。这种对象必须加以同步或避免并发访问。
总结:
- 线程安全的类型:如果一个对象的类型是线程安全的(如通过互斥锁保护),它不能成为 API 竞争的源头。
- 线程兼容的类型:如果对象的状态不会在并发访问中被修改,那么即使多个线程同时访问该对象,也不会发生 API 竞争。
- 静态变量与线程安全:静态变量在多线程环境中需要特别注意,如果多个线程并发访问并修改它们,可能会导致数据竞争。可以通过互斥锁或其他同步机制来避免这个问题。
通过合理的设计和同步机制,我们可以有效避免 API 竞争,确保多线程程序的安全性和正确性。
识别 API 竞争条件(API Races)
API 竞争(API Race)指的是在多线程环境中,多个线程并发访问同一对象并试图对其进行操作,导致不一致的结果或不可预测的行为。为避免这些问题,我们需要确保访问共享数据时采取正确的同步机制。下面是一些典型的案例以及如何判断和避免 API 竞争。
如何确保没有 API 竞争条件
一行代码如果要保证没有 API 竞争条件,必须满足以下条件:
- 不调用线程敌对函数(thread-hostile functions)。
- 所有输入参数必须是活动的(live),即没有被销毁或处于非法状态。
- 每个输入对象必须满足下列其中一个条件:
- 不被其他线程访问。
- 线程安全(thread-safe):即可以在多线程环境下安全使用。
- 线程兼容(thread-compatible)且 不会被任何线程修改。
示例 1: 数据竞争(Data Race)
代码示例:
vector<int> shared_vec = {0, 0};
void thread1() {// 修改第一个元素++shared_vec[0];
}
void thread2() {// 修改第二个元素++shared_vec[1];
}
分析:
- 问题:多个线程对同一
shared_vec
进行操作,虽然它们操作的是不同的元素([0]
和[1]
),但如果没有合适的同步,可能会发生数据竞争。 - 解决方案:对于不同的线程修改同一共享数据的情况,确保每个操作的同步性,或者确保每个线程访问的数据是独立的且不发生冲突。
示例 2: 线程不安全的 vector<bool>
代码示例:
vector<bool> shared_vec = {false, false};
void thread1() {// 修改第一个元素shared_vec[0] = true;
}
void thread2() {// 修改第二个元素shared_vec[1] = true;
}
分析:
- 问题:
vector<bool>
是一个特别的案例,它并没有真正存储bool
类型,而是以压缩的方式存储,导致其并不是线程安全的。在多个线程并发写入时会发生数据竞争。 - 解决方案:避免使用
vector<bool>
,或者确保线程间访问时的同步。
示例 3: 对 vector<int>
的并发修改
代码示例:
vector<int> shared_vec = {0, 0};
void thread1() {// 修改第一个元素++shared_vec[0];
}
void thread2() {// 修改第二个元素++shared_vec[1];
}
分析:
- 问题:类似前面的例子,尽管
shared_vec[0]
和shared_vec[1]
是不同的元素,但它们仍然是shared_vec
的成员,可能会发生数据竞争。 - 解决方案:通过锁(如
std::mutex
)来同步对shared_vec
的访问,或者在访问时确保不会发生冲突。
示例 4: 并发访问 std::vector<int>
的部分范围
代码示例:
// 对迭代器区间内的每个元素进行加 1
template <typename Iterator>
void f(Iterator begin, Iterator end) {for (Iterator it = begin; it != end; ++it)++*it;
}
vector<int> v = {1, 2, 3};
void thread1() {f(v.begin(), v.begin() + 2); // 修改前两个元素
}
void thread2() {f(v.begin() + 1, v.end()); // 修改后两个元素
}
分析:
- 问题:这段代码在两个线程中并发执行,
thread1
和thread2
都试图修改v
中的不同部分([0, 1]
和[1, 2]
)。这意味着两者在操作相同的元素时会发生数据竞争。 - 解决方案:确保在访问共享资源时加锁,或者重构代码避免同时操作共享数据。
示例 5: 线程不安全的成员函数
代码示例:
class Widget {int* counter_;
public:Widget(int* counter) : counter_(counter) {}// 线程不安全!void Twiddle() {++*counter_;}
};
Widget MakeWidget();
void thread1() {Widget w = MakeWidget();w.Twiddle();
}
void thread2() {Widget w = MakeWidget();w.Twiddle();
}
分析:
- 问题:多个线程可能同时调用
Twiddle()
,而counter_
是一个指向共享数据的指针。如果没有同步机制,可能会导致数据竞争。 - 解决方案:使用锁来保护
counter_
的访问,确保每次只有一个线程可以修改共享的counter_
。
推荐的最佳实践
对于库代码:
- 使类型线程兼容(thread-compatible):尽量设计为线程兼容,如果有必要,再设计为线程安全(thread-safe)。
- 明确文档化:清晰地文档化哪些类型是线程安全的,哪些是线程不兼容的。推荐将其他类型显式标注为线程兼容(thread-compatible)。
- 避免暴露内部状态:在设计中小心地暴露子对象,尤其是不可变的数据。
- 避免使用线程不安全的函数:如避免隐式共享的可变状态,避免使用指向共享数据的私有指针。
对于应用程序代码:
- 使共享对象线程安全:或者确保它们是线程兼容且不可变的。不要让它们成为并发操作的源头。
总结
- 线程安全:在多线程环境中,不会因并发操作导致数据损坏或未定义行为。使用锁、原子操作等技术来确保线程安全。
- 线程兼容:即对象可以在多个线程中并发访问,但不会被修改。如果没有修改操作,可以认为是线程兼容的。
- API 竞争:多个线程对同一对象的不同操作可能会导致不一致的结果。通过合理设计数据访问和同步机制,可以避免这种情况。
总之,确保多线程程序的正确性需要考虑对象的访问权限、线程安全性以及同步机制,尤其是避免并发访问导致的 API 竞争条件。