并行与并发,文件系统与一些资料分享
并行与并发
并行基础
std::thread
用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含 <thread>
头文件, 它提供了很多基本的线程操作,例如 get_id()
来获取所创建线程的线程 ID,使用 join()
来等待一个线程结束(与该线程汇合)等等,例如:1
2
3
4
5
6
7
8
9
10
int main() {
std::thread t([](){
std::cout << "hello world." << std::endl;
});
t.join();
return 0;
}
创建一个新线程的基本步骤如下:
- 定义一个函数或 lambda 表达式作为线程的执行体。
- 使用
std::thread
类的构造函数创建一个新线程,并传入线程执行体。 - 管理线程的生命周期,通常使用
join()
或detach()
方法
std::thread
类提供了以下常用的成员函数:
join()
: 阻塞当前线程,直到被关联的线程执行完毕。detach()
: 将线程设置为分离状态,主线程不会等待子线程结束。get_id()
: 获取线程的唯一标识符。swap(other)
: 交换两个线程对象的底层线程。native_handle()
: 获取底层操作系统的线程句柄。
1 |
|
互斥量与临界区
我们在操作系统、亦或是数据库的相关知识中已经了解过了有关并发技术的基本知识,mutex
就是其中的核心之一。 C++11 引入了 mutex
相关的类,其所有相关的函数都放在 <mutex>
头文件中。
std::mutex
是 C++11 中最基本的互斥量类,可以通过构造 std::mutex
对象创建互斥量, 而通过其成员函数 lock()
可以进行上锁,unlock()
可以进行解锁。
但是在实际编写代码的过程中,最好不去直接调用成员函数, 因为调用成员函数就需要在每个临界区的出口处调用 unlock()
,当然,还包括异常。
而 C++11 为互斥量提供了一个 RAII 机制的模板类 std::lock_guard
。
RAII 在不失代码简洁性的同时,很好的保证了代码的异常安全性。
在 RAII 用法下,对于临界区的互斥量的创建只需要在作用域的开始部分,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int v = 1;
void critical_section(int change_v) {
static std::mutex mtx;
std::lock_guard<std::mutex> lock(mtx);
// 执行竞争操作
v = change_v;
// 离开此作用域后 mtx 会被释放
}
int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();
std::cout << v << std::endl;
return 0;
}
C++ 保证了所有栈对象在生命周期结束时会被销毁,所以这样的代码也是异常安全的。
- 而
std::unique_lock
则是相对于std::lock_guard
出现的,std::unique_lock
更加灵活,std::unique_lock
的对象会以独占所有权(没有其他的unique_lock
对象同时拥有某个mutex
对象的所有权) 的方式管理mutex
对象上的上锁和解锁的操作。所以在并发编程中,推荐使用std::unique_lock
。 std::lock_guard
不能显式的调用lock
和unlock
, 而std::unique_lock
可以在声明后的任意位置调用, 可以缩小锁的作用范围,提供更高的并发度。- 如果用到了条件变量
std::condition_variable::wait
则必须使用std::unique_lock
作为参数。
使用lock_guard代替lock,unlock成员函数->使用unique_lock替代lock_guard可以独占并显式调用lock,unlock.
std::lock_guard
是一个非常简单的互斥锁管理器。它会在构造时自动获取互斥锁,并在析构时自动释放互斥锁。这样可以确保互斥锁的正确使用,并避免手动解锁的错误。
std::unique_lock
提供了更多的灵活性和控制。它可以手动锁定和解锁互斥锁,并支持延迟锁定、有条件地等待等功能
1 |
|
1 |
|
期物(Future)
期物(Future)表现为 std::future
,它提供了一个访问异步操作结果的途径,这句话很不好理解。 为了理解这个特性,我们需要先理解一下在 C++11 之前的多线程行为。
试想,如果我们的主线程 A 希望新开辟一个线程 B 去执行某个我们预期的任务,并返回我一个结果。 而这时候,线程 A 可能正在忙其他的事情,无暇顾及 B 的结果, 所以我们会很自然的希望能够在某个特定的时间获得线程 B 的结果。
在 C++11 的 std::future
被引入之前,通常的做法是: 创建一个线程 A,在线程 A 里启动任务 B,当准备完毕后发送一个事件,并将结果保存在全局变量中。 而主函数线程 A 里正在做其他的事情,当需要结果的时候,调用一个线程等待函数来获得执行的结果。
C++11 提供的 std::future
简化了这个流程,可以用来获取异步任务的结果。 自然地,我们很容易能够想象到把它作为一种简单的线程同步手段,即屏障(barrier)
额外使用 std::packaged_task
,它可以用来封装任何可以调用的目标,从而用于实现异步的调用。 举例来说:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main() {
// 将一个返回值为7的 lambda 表达式封装到 task 中
// std::packaged_task 的模板参数为要封装函数的类型
std::packaged_task<int()> task([](){return 7;});
// 获得 task 的期物
std::future<int> result = task.get_future(); // 在一个线程中执行 task
std::thread(std::move(task)).detach();
std::cout << "waiting...";
result.wait(); // 在此设置屏障,阻塞到期物的完成
// 输出执行结果
std::cout << "done!" << std:: endl << "future result is "
<< result.get() << std::endl;
return 0;
}
在封装好要调用的目标后,可以使用 get_future()
来获得一个 std::future
对象,以便之后实施线程同步。
future对象有wait和get方法分别等待与获得结果.
std::promise
是 C++11 标准库中提供的一个类,它用于在异步任务中设置返回值或异常。它通常与std::future
配合使用,形成一种生产者-消费者的模式。简单地说,
std::promise
就像一个”承诺”,它代表着一个正在进行的异步操作。当这个异步操作完成时,std::promise
就会”兑现”这个承诺,也就是设置返回值或异常。而std::future
则是用来”消费”这个承诺,也就是获取异步操作的结果.它是一个非常有用的工具,可以帮助您在异步任务中设置返回值或异常,并与
std::future
一起,构建更加复杂和强大的并发程序。
1 |
|
1 |
|
1 |
|
条件变量
条件变量 std::condition_variable
是为了解决死锁而生,当互斥操作不够用而引入的。 比如,线程可能需要等待某个条件为真才能继续执行, 而一个忙等待循环中可能会导致所有其他线程都无法进入临界区使得条件为真时,就会发生死锁。
所以,condition_variable
对象被创建出现主要就是用于唤醒等待线程从而避免死锁。 std::condition_variable
的 notify_one()
用于唤醒一个线程; notify_all()
则是通知所有线程1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
int main() {
std::queue<int> produced_nums;
std::mutex mtx;
std::condition_variable cv;
bool notified = false; // 通知信号
// 生产者
auto producer = [&]() {
for (int i = 0; ; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(900));
std::unique_lock<std::mutex> lock(mtx);
std::cout << "producing " << i << std::endl;
produced_nums.push(i);
notified = true;
cv.notify_all(); // 此处也可以使用 notify_one
}
};
// 消费者
auto consumer = [&]() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
while (!notified) { // 避免虚假唤醒
cv.wait(lock);
}
// 短暂取消锁,使得生产者有机会在消费者消费空前继续生产
lock.unlock();
// 消费者慢于生产者
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
lock.lock();
while (!produced_nums.empty()) {
std::cout << "consuming " << produced_nums.front() << std::endl;
produced_nums.pop();
}
notified = false;
}
};
// 分别在不同的线程中运行
std::thread p(producer);
std::thread cs[2];
for (int i = 0; i < 2; ++i) {
cs[i] = std::thread(consumer);
}
p.join();
for (int i = 0; i < 2; ++i) {
cs[i].join();
}
return 0;
}
值得一提的是,在生产者中我们虽然可以使用 notify_one()
,但实际上并不建议在此处使用, 因为在多消费者的情况下,我们的消费者实现中简单放弃了锁的持有,这使得可能让其他消费者争夺此锁,从而更好的利用多个消费者之间的并发。
话虽如此,但实际上因为 std::mutex
的排他性, 我们根本无法期待多个消费者能真正意义上的并行消费队列的中生产的内容,我们仍需要粒度更细的手段。
原子操作与内存模型
上面的例子中,编译器可能对变量 notified
存在优化,例如将其作为一个寄存器的值, 从而导致消费者线程永远无法观察到此值的变化.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
int a = 0;
volatile int flag = 0;
std::thread t1([&]() {
while (flag != 1);
int b = a;
std::cout << "b = " << b << std::endl;
});
std::thread t2([&]() {
a = 5;
flag = 1;
});
t1.join();
t2.join();
return 0;
}
从直观上看,t2
中 a = 5;
这一条语句似乎总在 flag = 1;
之前得到执行,而 t1
中 while (flag != 1)
似乎保证了 std::cout << "b = " << b << std::endl;
不会再标记被改变前执行。从逻辑上看,似乎 b
的值应该等于 5。 但实际情况远比此复杂得多,或者说这段代码本身属于未定义的行为,因为对于 a
和 flag
而言,他们在两个并行的线程中被读写, 出现了竞争。除此之外,即便我们忽略竞争读写,仍然可能受 CPU 的乱序执行,编译器对指令的重排的影响, 导致 a = 5
发生在 flag = 1
之后。从而 b
可能输出 0。
原子操作
std::mutex
可以解决上面出现的并发读写的问题,但互斥锁是操作系统级的功能, 这是因为一个互斥锁的实现通常包含两条基本原理:
- 提供线程间自动的状态转换,即『锁住』这个状态
- 保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离
这是一组非常强的同步条件,换句话说当最终编译为 CPU 指令时会表现为非常多的指令(我们之后再来看如何实现一个简单的互斥锁)。 这对于一个仅需原子级操作(没有中间态)的变量,似乎太苛刻了。
关于同步条件的研究有着非常久远的历史,我们在这里不进行赘述。读者应该明白,现代 CPU 体系结构提供了 CPU 指令级的原子操作, 因此在多线程下共享变量的读写这一问题上, C++11 中还引入了 std::atomic
模板,使得我们能实例化原子类型,并将一个原子写操作从一组指令,最小化到单个 CPU 指令。1
std::atomic<int> counter;
其为整数或浮点数的原子类型提供了基本的数值成员函数,举例来说, 包括 fetch_add
, fetch_sub
等,同时通过重载方便的提供了对应的 +
,-
版本1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::atomic<int> count = {0};
int main() {
std::thread t1([](){
count.fetch_add(1);
});
std::thread t2([](){
count++; // 等价于 fetch_add
count += 1; // 等价于 fetch_add
});
t1.join();
t2.join();
std::cout << count << std::endl;
return 0;
}
并非所有的类型都能提供原子操作,这是因为原子操作的可行性取决于具体的 CPU 架构,以及所实例化的类型结构是否能够满足该 CPU 架构对内存对齐 条件的要求,因而我们总是可以通过 std::atomic<T>::is_lock_free
来检查该原子类型是否需支持原子操作.1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct A {
float x;
int y;
long long z;
};
int main() {
std::atomic<A> a;
std::cout << std::boolalpha << a.is_lock_free() << std::endl;
return 0;
}
一致性模型
并行执行的多个线程,从某种宏观层面上讨论,可以粗略的视为一种分布式系统。 在分布式系统中,任何通信乃至本地操作都需要消耗一定时间,甚至出现不可靠的通信。
如果我们强行将一个变量 v
在多个线程之间的操作设为原子操作,即任何一个线程在操作完 v
后, 其他线程均能同步感知到 v
的变化,则对于变量 v
而言,表现为顺序执行的程序,它并没有由于引入多线程 而得到任何效率上的收益。对此有什么办法能够适当的加速呢?答案便是削弱原子操作的在进程间的同步条件。
从原理上看,每个线程可以对应为一个集群节点,而线程间的通信也几乎等价于集群节点间的通信。 削弱进程间的同步条件,通常我们会考虑四种不同的一致性模型:
线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。
```
x.store(1) x.load()
T1 ————-+————————+———>
T2 —————————-+——————->
x.store(2)1
2
3
4
5
在这种情况下线程 `T1`, `T2` 对 `x` 的两次写操作是原子的,且 `x.store(1)` 是严格的发生在 `x.store(2)` 之前,`x.store(2)` 严格的发生在 `x.load()` 之前。 值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。
2. 顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。
x.store(1) x.store(3) x.load()
T1 ————-+—————-+—————+——->
T2 ———————-+———————————>
x.store(2)
或者
x.store(1) x.store(3) x.load()
T1 ————-+—————-+—————+——->
T2 ———+———————————————->
x.store(2)1
2
3
4
5
在顺序一致性的要求下,`x.load()` 必须读到最近一次写入的数据,因此 `x.store(2)` 与 `x.store(1)` 并无任何先后保障,即 只要 `T2` 的 `x.store(2)` 发生在 `x.store(3)` 之前即可。
3. 因果一致性:它的要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求。
a = 1 b = 2
T1 ——+—————-+——————————————>
T2 ———+——————————+————+————>
x.store(3) c = a + b y.load()
或者
a = 1 b = 2
T1 ——+—————-+——————————————>
T2 ———+——————————+————+————>
x.store(3) y.load() c = a + b
亦或者
b = 2 a = 1
T1 ——+—————-+——————————————>
T2 ———+——————————+————+————>
y.load() c = a + b x.store(3)1
2
3
4
5
上面给出的三种例子都是属于因果一致的,因为整个过程中,只有 `c` 对 `a` 和 `b` 产生依赖,而 `x` 和 `y` 在此例子中表现为没有关系(但实际情况中我们需要更详细的信息才能确定 `x` 与 `y` 确实无关)
4. 最终一致性:是最弱的一致性要求,它只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。因此我们甚至可以对此条件稍作加强,例如规定某个操作被观察到的时间总是有界的。当然这已经不在我们的讨论范围之内了。
x.store(3) x.store(4)
T1 ——+—————-+——————————————————————>
T2 ————-+——————+——————————+————+————>
x.read x.read() x.read() x.read()1
2
3
在上面的情况中,如果我们假设 x 的初始值为 0,则 `T2` 中四次 `x.read()` 结果可能但不限于以下情况:
3 4 4 4 // x 的写操作被很快观察到
0 3 3 4 // x 的写操作被观察到的时间存在一定延迟
0 0 0 4 // 最后一次读操作读到了 x 的最终值,但此前的变化并未观察到
0 0 0 0 // 在当前时间段内 x 的写操作均未被观察到,
// 但未来某个时间点上一定能观察到 x 为 4 的情况1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#### 内存顺序
为了追求极致的性能,实现各种强度要求的一致性,C++11 为原子操作定义了六种不同的内存顺序 `std::memory_order` 的选项,表达了四种多线程间的同步模型:
1. 宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过 `std::memory_order_relaxed` 指定。我们来看一个例子:
```cpp
std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_relaxed);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;
释放/消费模型:在此模型中,我们开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。具体而言,线程 A 完成了三次对
x
的写操作,线程B
仅依赖其中第三次x
的写操作,与x
的前两次写行为无关,则当A
主动x.release()
时候(即使用std::memory_order_release
),选项std::memory_order_consume
能够确保B
在调用x.load()
时候观察到A
中第三次对x
的写操作。我们来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 初始化为 nullptr 防止 consumer 线程从野指针进行读取
std::atomic<int*> ptr(nullptr);
int v;
std::thread producer([&]() {
int* p = new int(42);
v = 1024;
ptr.store(p, std::memory_order_release);
});
std::thread consumer([&]() {
int* p;
while(!(p = ptr.load(std::memory_order_consume)));
std::cout << "p: " << *p << std::endl;
std::cout << "v: " << v << std::endl;
});
producer.join();
consumer.join();释放/获取模型:在此模型下,我们可以进一步加紧对不同线程间原子操作的顺序的限制,在释放
std::memory_order_release
和获取std::memory_order_acquire
之间规定时序,即发生在释放(release)操作之前的所有写操作,对其他线程的任何获取(acquire)操作都是可见的,亦即发生顺序(happens-before)。可以看到,
std::memory_order_release
确保了它之前的写操作不会发生在释放操作之后,是一个向后的屏障(backward),而std::memory_order_acquire
确保了它之前的写行为不会发生在该获取操作之后,是一个向前的屏障(forward)。对于选项std::memory_order_acq_rel
而言,则结合了这两者的特点,唯一确定了一个内存屏障,使得当前线程对内存的读写不会被重排并越过此操作的前后:我们来看一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20std::vector<int> v;
std::atomic<int> flag = {0};
std::thread release([&]() {
v.push_back(42);
flag.store(1, std::memory_order_release);
});
std::thread acqrel([&]() {
int expected = 1; // must before compare_exchange_strong
while(!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
expected = 1; // must after compare_exchange_strong
// flag has changed to 2
});
std::thread acquire([&]() {
while(flag.load(std::memory_order_acquire) < 2);
std::cout << v.at(0) << std::endl; // must be 42
});
release.join();
acqrel.join();
acquire.join();在此例中我们使用了
compare_exchange_strong
比较交换原语(Compare-and-swap primitive),它有一个更弱的版本,即compare_exchange_weak
,它允许即便交换成功,也仍然返回false
失败。其原因是因为在某些平台上虚假故障导致的,具体而言,当 CPU 进行上下文切换时,另一线程加载同一地址产生的不一致。除此之外,compare_exchange_strong
的性能可能稍差于compare_exchange_weak
,但大部分情况下,鉴于其使用的复杂度而言,compare_exchange_weak
应该被有限考虑。顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过
std::memory_order_seq_cst
进行指定。最后来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12std::atomic<int> counter = {0};
std::vector<std::thread> vt;
for (int i = 0; i < 100; ++i) {
vt.emplace_back([&](){
counter.fetch_add(1, std::memory_order_seq_cst);
});
}
for (auto& t : vt) {
t.join();
}
std::cout << "current counter:" << counter << std::endl;这个例子与第一个宽松模型的例子本质上没有区别,仅仅只是将原子操作的内存顺序修改为了
memory_order_seq_cst
,有兴趣的读者可以自行编写程序测量这两种不同内存顺序导致的性能差异。
C++11 语言层提供了并发编程的相关支持,比如std::thread
, std::mutex
, std::future
这些并发编程中不可回避的重要工具。
除此之外还介绍了 C++11 最重要的几个特性之一的『内存模型』, 它们为 C++ 在标准化高性能计算中提供了重要的基础。
文件系统
文件系统库提供了文件系统、路径、常规文件、目录等等相关组件进行操作的相关功能。和正则表达式库类似,他也是最先由 boost 发起,并最终被合并为 C++ 标准的众多库之一。
C++17 引入了文件系统标准库(
<filesystem>
)来提供对文件系统的操作支持。该标准库定义了一组与文件系统相关的类和函数,使得在C++程序中操作文件和目录变得更加方便和跨平台
- 文件系统:
std::filesystem
命名空间包含了与文件系统相关的类和函数。- 主要类型包括
path
、directory_entry
、directory_iterator
和recursive_directory_iterator
。 - 这些类型允许你查询、遍历和操作文件系统中的目录和文件。
- 路径:
std::filesystem::path
类用于表示文件系统中的路径。- 它可以表示绝对路径和相对路径,并提供了许多处理路径的方法,如拼接、分割等。
- 你可以使用
operator/
来拼接路径,如path1 / "subdir" / "file.txt"
。
常规文件:
std::filesystem
库提供了一些函数来操作常规文件,如std::filesystem::exists()
、std::filesystem::file_size()
、std::filesystem::copy_file()
和std::filesystem::remove()
。- 这些函数可以帮助你检查文件是否存在、获取文件大小、复制文件以及删除文件等。
<filesystem>
标准库还提供了一些与文件系统链接相关的功能硬链接和软链接:
std::filesystem::create_hard_link()
函数可以创建文件的硬链接。std::filesystem::create_symlink()
函数可以创建文件或目录的软链接(符号链接)。std::filesystem::is_symlink()
函数可以检查某个路径是否为软链接。
- 文件路径规范化:
std::filesystem::canonical()
函数可以获取某个路径的标准形式(规范路径)。- 这在处理涉及软链接的路径时很有帮助,可以消除软链接带来的路径歧义。
- 路径比较:
std::filesystem::equivalent()
函数可以判断两个路径是否指向同一个文件或目录。- 即使路径字符串不同,如果指向同一个实体,也会返回true。
- 路径解析:
std::filesystem::proximate()
函数可以获取某个路径相对于另一个路径的相对路径。- 这在处理涉及多个目录的路径时很有用
1 |
|
C++17 文件系统标准库的一些主要组成部分:
文件系统
std::filesystem::path
: 表示文件系统路径的类。可以用于创建、查询和操作路径。std::filesystem::directory_entry
: 表示目录项的类,包含文件或目录的信息。std::filesystem::directory_iterator
: 目录迭代器,可用于遍历目录中的内容。std::filesystem::recursive_directory_iterator
: 递归目录迭代器,可用于遍历目录及其子目录。- 一些常用函数:
std::filesystem::exists()
: 检查路径是否存在std::filesystem::is_regular_file()
: 检查路径是否为常规文件std::filesystem::is_directory()
: 检查路径是否为目录std::filesystem::file_size()
: 获取文件大小std::filesystem::copy_file()
: 复制文件std::filesystem::remove()
: 删除文件或目录std::filesystem::create_directory()
: 创建目录
1 |
|
常用宏
c++兼容c1
2
3
4
5
6
7
8
9
10
extern "C" {
void funcc(int a) {
std::cout << "funcc" << std::endl;
return;
}
}
定义报错logger1
2
3
使用1
LOG("Error: %d, %s", 42, "message");
相关资料
C++ Core Guidelines (isocpp.github.io)
模板编程
阅读须知 | 现代 C++ 模板教程 (mq-b.github.io)
C++模板编程 - tlanyan (itlanyan.com)
并发编程
Introduction · C++并发编程实战 (gitbooks.io)
之后的C++20
C++20 - cppreference.com
xiaoweiChen/CXX20-The-Complete-Guide: 《C++20 The Complete Guide》的非专业个人翻译 (github.com)
虽然说了很多次,但我还是想谈谈c++本身的应用,在存储和网络上都很多,你也可以用它来造其他轮子.
应用
- 网络编程/Linux服务器编程
- 计算机图形学
- 桌面应用编程
- 音视频编程
- 存储/数据库