الخوارزميات المتوازية وسياسات التنفيذ
استفد من C++17 الموازي STL لموازاة الفرز والتحويل والتخفيض مع سياسات التنفيذ. فهم متى يساعد التوازي، وقانون أمدال، والتصميم العملي لتجمع الخيوط.
Parallel STL - التوازي السهل
أضاف C++17 سياسات التنفيذ إلى معظم وظائف. من خلال تمرير سياسة كوسيطة أولى، فإنك تطلب من التنفيذ أن يوازن الخوارزمية - ربما باستخدام سلاسل رسائل متعددة، أو SIMD، أو كليهما - دون تغيير أي رمز آخر.
هذه هي أسهل طريقة لإضافة التوازي إلى C++: قم بإجراء مكالمةstd::sortأوstd::transformأوstd::for_eachالموجودة لديك، وأضفstd::execution::parمسبقًا، واترك التنفيذ يقوم بالباقي. بالطبع، يجب عليك التأكد من أن عملياتك آمنة للتنفيذ المتزامن (لا توجد سباقات بيانات في المسندات، ولا توجد حالة مشتركة قابلة للتغيير).
شرح سياسات التنفيذ
هناك ثلاث سياسات تنفيذ قياسية محددة في. يوفر كل منها ضمانات مختلفة حول التوازي والتوجيه.
#include <algorithm>
#include <execution>
#include <vector>
#include <iostream>
#include <chrono>
int main() {
std::vector<int> data(10'000'000);
std::iota(data.begin(), data.end(), 0);
// Sequential — same as no policy
auto t1 = std::chrono::high_resolution_clock::now();
std::sort(std::execution::seq, data.begin(), data.end(),
std::greater<>{});
auto t2 = std::chrono::high_resolution_clock::now();
// Parallel — may use multiple threads
std::sort(std::execution::par, data.begin(), data.end());
auto t3 = std::chrono::high_resolution_clock::now();
// Parallel + vectorized — threads AND SIMD
std::sort(std::execution::par_unseq, data.begin(), data.end(),
std::greater<>{});
auto t4 = std::chrono::high_resolution_clock::now();
auto ms = [](auto a, auto b) {
return std::chrono::duration_cast<std::chrono::milliseconds>(b - a).count();
};
std::cout << "seq: " << ms(t1, t2) << "ms\n";
std::cout << "par: " << ms(t2, t3) << "ms\n";
std::cout << "par_unseq: " << ms(t3, t4) << "ms\n";
return 0;
}الأمراض المنقولة جنسيا::تقليل مقابل الأمراض المنقولة جنسيا::تتراكم
يعالجstd::accumulateالعناصر من اليسار إلى اليمين بشكل صارم ولا يمكن أن تكون متوازية. يتيحstd::reduce(C++17) للتنفيذ معالجة العناصر بأي ترتيب والجمع بين النتائج الجزئية — مما يتيح التنفيذ المتوازي. يجب أن تكون العملية ارتباطية وتبادلية** حتى يتمكنreduceمن الحصول على النتائج الصحيحة.
#include <numeric>
#include <execution>
#include <vector>
#include <iostream>
int main() {
std::vector<double> data(1'000'000, 1.0);
// Sequential accumulate — always left-to-right
double sum1 = std::accumulate(data.begin(), data.end(), 0.0);
// Parallel reduce — may split and combine in any order
double sum2 = std::reduce(std::execution::par,
data.begin(), data.end(), 0.0);
// Transform-reduce: parallel dot product
std::vector<double> a(1'000'000, 2.0);
std::vector<double> b(1'000'000, 3.0);
double dot = std::transform_reduce(
std::execution::par,
a.begin(), a.end(), b.begin(),
0.0); // default: multiply + add
std::cout << "accumulate: " << sum1 << "\n";
std::cout << "reduce: " << sum2 << "\n";
std::cout << "dot product: " << dot << "\n"; // 6'000'000
return 0;
}عند استخدام سياسات التنفيذ المتوازي، قد يتم استدعاء القابل للاستدعاء (المسند، وظيفة التحويل، عامل التخفيض) الذي تمرره إلى الخوارزمية بشكل متزامن من سلاسل رسائل متعددة. إذا كان العنصر القابل للاستدعاء الخاص بك يصل إلى الحالة المشتركة القابلة للتغيير، فسيكون لديك سباق بيانات:
int count = 0;
std::for_each(std::execution::par, v.begin(), v.end(),
[&count](int x) {
if (x > 0) ++count; // DATA RACE! Multiple threads increment count
});Use std::atomic, thread-local accumulators, or better yet, use std::reduce / std::count_if التي تتعامل مع التجميع بأمان.
قانون أمدال – عندما يساعد التوازي
قانون أمدال ينص على أن الحد الأقصى للتسريع من التوازي محدود بـ الكسر المتسلسل لبرنامجك. إذا كان يجب تنفيذ 10% من العمل بالتتابع، فحتى مع الخيوط اللانهائية، فإن الحد الأقصى للتسريع هو 10x.
السرعة = 1 / (S + P/N)، حيث S هو الكسر المتسلسل، P هو الكسر المتوازي، وN هو عدد الخيوط.
يحتوي التوازي أيضًا على حمل : إنشاء سلسلة الرسائل، والمزامنة، والتنافس على ذاكرة التخزين المؤقت، والمشاركة الزائفة. بالنسبة لمجموعات البيانات الصغيرة، يمكن أن تتجاوز النفقات العامة الفائدة. القواعد الأساسية:
- الفرز: يساعد Parallelstd::sortعادةً على ما يزيد عن 100 ألف عنصر تقريبًا.
- Transform/for_each: فوائد تزيد عن 10 آلاف عنصر تقريبًا مع عمل غير تافه لكل عنصر.
- تقليل: فوائد تزيد عن 100 ألف عنصر تقريبًا أو مع عمليات باهظة الثمن.
- القياس دائمًا — الملف الشخصي معstd::execution::parوبدونه.
نظرة عامة على تجمع الخيوط العملي
لا يوفر معيار C++ تجمع مؤشرات الترابط (اعتبارًا من C++23)، ولكن النمط واضح ومباشر: مجموعة ثابتة من مؤشرات الترابط العاملة تسحب المهام من قائمة انتظار مشتركة. هنا رسم الحد الأدنى.
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>
#include <vector>
class ThreadPool {
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
public:
explicit ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // run outside the lock
}
});
}
}
void enqueue(std::function<void()> task) {
{
std::lock_guard lock(mtx_);
tasks_.push(std::move(task));
}
cv_.notify_one();
}
~ThreadPool() {
{
std::lock_guard lock(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& w : workers_) w.join();
}
};
int main() {
ThreadPool pool(4);
for (int i = 0; i < 20; ++i) {
pool.enqueue([i] {
std::cout << "Task " << i << " on thread "
<< std::this_thread::get_id() << "\n";
});
}
// pool destructor waits for all tasks
return 0;
}تفضلstd::execution::parعلى التوازي المدلفن يدويًا للخوارزميات القياسية - فالتنفيذ يعرف أجهزتك. تأكد دائمًا من أن قابليتك للاستدعاء خالية من سباقات البيانات والآثار الجانبية. استخدمstd::reduceبدلاً منstd::accumulateعندما تحتاج إلى التوازي. قياس مرجعي بأحجام بيانات واقعية؛ بالنسبة للمدخلات الصغيرة، قد يكون التسلسل أسرع. تذكر أنpar_unseqيحظر استدعاء أي شيء يحصل على قفل (بما في ذلك تخصيص الذاكرة على بعض الأنظمة الأساسية).
- تضيف سياسات تنفيذ C++17 (
seq، وpar، وpar_unseq) التوازي إلى الخوارزميات القياسية مع الحد الأدنى من تغييرات التعليمات البرمجية std::reduceهو البديل المتوازي لـstd::accumulate- يجب أن تكون عمليتك ترابطية وتبادلية- قانون أمدال يحد من التسريع: يصبح الجزء المتسلسل من الكود الخاص بك هو عنق الزجاجة
- الخوارزميات المتوازية لها أعباء إضافية، وهي مفيدة فقط لمجموعات البيانات الكبيرة بما فيه الكفاية أو العمل المكلف لكل عنصر
- تأكد دائمًا من أن العناصر القابلة للاستدعاء التي تم تمريرها إلى الخوارزميات المتوازية خالية من سباقات البيانات
- تعيد تجمعات مؤشرات الترابط استخدام مجموعة ثابتة من سلاسل الرسائل لتجنب تكلفة إنشاء سلاسل الرسائل للعديد من المهام الصغيرة
اختبار — اختبر معرفتك
(15 نقطة خبرة)1. لماذا لا يمكن استخدام`std::accumulate`مع سياسات التنفيذ؟
2. ما القيد الذي يضعه`std::execution::par_unseq`على القابل للاستدعاء؟
3. حسب قانون أمدال، ما هو الحد الأقصى للسرعة إذا كان 20% من البرنامج متسلسل؟