اذهب إلى المحتوى

يُعدّ تمرير الرسائل طريقةً جيدةً للتعامل مع التزامن ولكنها ليست الطريقة الوحيدة، إذ أن هناك طريقةٌ أخرى لوصول خيوط threads متعددة إلى ذات بيانات المُشاركة. ضع بالحسبان هذا الجزء من الشعار من توثيق لغة جو Go "لا تتواصل بمشاركة الذاكرة".

كيف سيبدو التواصل من خلال مشاركة الذاكرة؟ وبالإضافة إلى ذلك، لماذا يحذر المدافعون عن تمرير الرسائل message-passing من استخدام مشاركة الذاكرة memory sharing؟

تشبه القنوات channels في أي لغة برمجة -بطريقة ما- الملكية الفردية لأنه بمجرد نقلك لقيمة ما إلى قناة يجب ألا تستخدم هذه القيمة بعدها. يشبه تزامن الذاكرة المشتركة الملكية المتعددة multiple ownership، إذ يمكن للخيوط المتعددة أن تصل إلى موقع الذاكرة ذاته في الوقت نفسه. كما رأينا سابقًا في مقال المؤشرات الذكية Smart Pointers في رست، فقد جعلت المؤشرات الذكية smart pointers الملكية المتعددة ممكنة، ويمكن للملكية المتعددة أن تعقد الأمر لأن الملّاك المختلفين بحاجة إلى إدارة. يساعد نظام رست وقواعد الملكية الخاصة به كثيرًا في جعل عملية الإدارة صحيحة. لنلقي على سبيل المثال نظرةً على كائنات المزامنة mutexes التي تعدّ واحدةً من أكثر بدائل التزامن شيوعًا للذاكرة المشتركة.

استعمال كائنات المزامنة للسماح بالوصول للبيانات عن طريق خيط واحد بالوقت ذاته

كائن المزامنة Mutex هو اختصارٌ للاستبعاد المتبادل mutual exclusion، أي يسمح كائن المزامنة لخيط واحد فقط أن يصل إلى بعض البيانات في أي وقت، وللوصول إلى البيانات في كائن المزامنة يجب أن يشير الخيط أولًا إلى أنه يريد الوصول عن طريق طلب الحصول على قفل كائن المزامنة mutex's lock؛ والقفل هو هيكل بيانات data structure يعد جزءًا من كائن المزامنة الذي يتتبع من لديه حاليًا وصولٌ حصري إلى البيانات، وبالتالي يُوصَف كائن المزامنة بأنه يحمي البيانات التي يحملها عبر نظام القفل.

لكائنات المزامنة سمعة بأنها صعبة الاستعمال لأنك يجب أن تتذكر قاعدتين:

  • يجب أن تحاول الحصول على القفل قبل استعمال البيانات.
  • يجب أن تلغي قفل البيانات عندما تنتهي من البيانات التي يحميها كائن المزامنة حتى يتسنّى للخيوط الأخرى الحصول على القفل.

لنأخذ تشبيهًا حقيقيًا لكائن المزامنة: تخيل حلقة نقاش في مؤتمر بميكروفون واحد فقط، إذ يجب على أحد أعضاء اللجنة أن يسأل أو يشير إلى أنه يريد استخدام الميكروفون عند رغبته بالتحدث وعندما يحصل على الميكروفون يمكنه التحدث بقدر ما يريد من الوقت ثم يسلّم الميكروفون إلى عضو اللجنة التالي الذي يطلب التحدث؛ وإذا نسي أحد أعضاء اللجنة تسليم الميكروفون عند الانتهاء منه فلن يتمكن أي شخص آخر من التحدث؛ إذا حدث خطأ في إدارة الميكروفون المشترك فلن تعمل حلقة النقاش على النحو المطلوب.

قد تكون إدارة كائنات المزامنة صعبة جدًا وهذا هو سبب تفضيل الكثير من الناس للقنوات، ومع ذلك لا يمكنك الحصول على قفل وفتحه بصورةٍ خاطئة في رست بفضل نظامها وقواعد ملكيتها الخاصة.

واجهة <Mutex<T البرمجية

لنبدأ باستعمال كائن مزامنة بسياق خيط وحيد single-threaded مثالًا عن كيفية استعمال كائن مزامنة كما هو موضح في الشيفرة 12:

اسم الملف: src/main.rs

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {:?}", m);
}

الشيفرة 12: تجربة واجهة <Mutex<T البرمجية بسياق خيط وحيد لبساطته

كما هو الحال مع العديد من الأنواع نُنشئ <Mutex<T باستعمال الدالة المرتبطة ‫new، ونستخدم التابع lock للوصول إلى البيانات داخل كائن المزامنة وذلك للحصول على القفل. سيحظر هذا الاستدعاء الخيط الحالي ولن تتمكن من فعل أي عمل حتى يحين دور الحصول على القفل.

يفشل استدعاء lock إذا هلع خيط آخر يحمل القفل، وفي هذه الحالة لن يتمكن أي شخص إطلاقًا من الحصول على القفل لذلك اخترنا unwrap بحيث يهلع الخيط هذا إذا حصلت هذه الحالة.

يمكننا أن نعالج القيمة التي حصلنا عليها التي تحمل الاسم num بعد حصولنا على القفل وستكون في هذه الحالة بمثابة مرجع متغير mutable يُشير إلى البيانات الموجودة بالداخل. يضمن نظام النوع type system حصولنا على قفل قبل استخدام القيمة في m، ونوع m هو <Mutex<i32 وليس i32 لذلك يجب علينا استدعاء lock حتى نتمكن من استخدام القيمة i32، ودعنا لا ننسى أن نظام النوع لن يسمح لنا بالوصول إلى قيمة i32 الداخلية بخلاف ذلك.

كما تعتقد فإن <Mutex<T هو مؤشر ذكي، وبدقة أكبر، يُعيد استدعاء lock مؤشرًا ذكيًا يدعى MutexGuard مُغلَّفًا في LockResult الذي تعاملنا معه في استدعاء unwrap، يُطبّق المؤشر الذكي MutexGuard السمة Deref ليشير إلى بياناتنا الداخلية، كما يحتوي المؤشر الذكي أيضًا على تطبيق للسمة Drop يُحرّر القفل تلقائيًا عندما يخرج MutexGuard عن النطاق وهو الأمر الذي يحدث بنهاية النطاق الداخلي. نتيجة لذلك لا نخاطر بنسيان تحرير القفل وحجب استخدام كائن المزامنة بواسطة الخيوط الأخرى لأن تحرير القفل يحدث تلقائيًا.

يمكننا طباعة قيمة كائن المزامنة بعد تحرير القفل وسنرى أننا تمكنا من تغيير القيمة ذات النوعi32 الداخلية إلى 6.

مشاركة <Mutex<T بين خيوط متعددة

دعنا نحاول الآن مشاركة قيمة بين خيوط متعددة باستخدام <Mutex<T، سنمرّ على عشرة خيوط ونجعل كل خيط منها يزيد قيمة العداد بمقدار 1 بحيث ينتقل العداد من القيمة 0 إلى 10. يحتوي المثال التالي في الشيفرة 13 على خطأ تصريفي compiler error، وسنستفيد من هذا الخطأ حتى نتعلم المزيد حول استعمال <Mutex<T وكيف سيساعدنا رست في استعماله بصورةٍ صحيحة.

اسم الملف: src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

الشيفرة 13: عشرة خيوط يزيد كل واحد منها عداد محميّ باستخدام <Mutex <T

ننشئ متغيرًا ندعوه counter ليحمل قيمة من النوع i32 داخل <Mutex<T كما فعلنا في الشيفرة 12، بعد ذلك نُنشئ عشرة خيوط عبر المرور iterate على مجال من الأرقام، ونستخدم لتحقيق ذلك thread::spawn ونمنح كل خيط المغلّف ذاته الذي ينقل العداد باتجاه الخيط ويحصل على قفل على <Mutex<T عن طريق استدعاء التابع lock ومن ثم يضيف القيمة 1 إلى القيمة الموجودة في كائن المزامنة. عندما ينتهي الخيط من تنفيذ مغلّفه يخرج num عن النطاق ويحرر القفل بحيث يستطيع خيطٌ آخر الحصول عليه.

نجمع كل مقابض الانضمام join handles في الخيط الرئيسي، ونستدعي بعد ذلك -كما فعلنا في الشيفرة 2 سابقًا- join على كل مقبض للتأكد من انتهاء جميع الخيوط، وعند هذه النقطة يحصل الخيط الرئيسي على القفل ويطبع نتيجة هذا البرنامج.

لمّحنا إلى أن هذا المثال لن يُصرَّف، لنتعرف على السبب الآن:

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here, in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error

تشير رسالة الخطأ إلى أن قيمة counter نُقِلت في التكرار السابق للحلقة، وتخبرنا رست أنه لا يمكننا نقل ملكية قفل counter إلى خيوط متعددة. لنصحّح الخطأ التصريفي بطريقة الملكية المتعددة التي ناقشناها سابقًا.

ملكية متعددة مع خيوط متعددة

تكلمنا في مقال سابق عن المالكين المتعددين لقيمة باستخدام المؤشر الذكي <Rc<T لإنشاء قيمة مرجعية معدودة، لننفّذ الشيء ذاته هنا ونلاحظ النتيجة. نغلّف <Mutex<T داخل <Rc<T ضمن الشيفرة 14 ونستنسخ <Rc<T قبل نقل الملكية إلى الخيط.

اسم الملف: src/main.rs

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

الشيفرة 14: محاولة استعمال <Rc<T للسماح لخيوط متعددة بامتلاك <Mutex<T

نصرّف الشيفرة البرمجية مرةً أخرى، ونحصل هذه المرة على أخطاء مختلفة، ويعلّمنا المصرّف الكثير من الأشياء.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `[closure@src/main.rs:11:36: 11:43]`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `[closure@src/main.rs:11:36: 11:43]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error

رسالة الخطأ هذه شديدة التعقيد، إليك الجزء المهم الذي يجب أن تركز عليه:

`Rc<Mutex<i32>>` cannot be sent between threads safely

يخبرنا المصرّف أيضًا عن السبب:

the trait `Send` is not implemented for `Rc<Mutex<i32>>`

سنتحدث عن Send في القسم التالي، إذ أنها أحد السمات التي تضمن أن الأنواع التي نستعملها مع الخيوط مخصصة للاستخدام في الحالات المتزامنة.

لسوء الحظ فإن <Rc<T ليس آمنًا للمشاركة عبر الخيوط، فعندما يُدير <Rc<T عدد المراجع فإنه يضيف عدد كل استدعاء إلى clone ويطرح من العدد عندما تُحرَّر كل نسخة clone، إلا أنه لا يستعمل أي أنواع تزامن أولية للتأكد من أن التغييرات التي حدثت على العدد لا يمكن مقاطعتها بواسطة خيط آخر. قد يؤدي هذا إلى عمليات عدّ خاطئة -أخطاء خفية يمكن أن تؤدي بدورها إلى تسريب الذاكرة memory leak أو تحرير قيمة ما قبل أن ننتهي منها- وما نحتاجه هنا هو نوع مثل <Rc<T تمامًا ولكنه نوع يُجري تغييرات على عدد المراجع بطريقة آمنة للخيوط.

عد المراجع الذري باستخدام <Arc<T

لحسن الحظ، يعد <Arc<T نوعًا مثل <Rc<Tوهو آمن للاستخدام في الحالات المتزامنة، إذ يرمز الحرف a إلى ذرّي atomic مما يعني أنه يُعد نوع عدّ مرجع ذري atomically reference counted type. تعدّ الأنواع الذرية Atomics نوعًا إضافيًا من أنواع التزامن الأولية concurrency primitive التي لن نغطيها بالتفصيل هنا. راجع توثيق المكتبة القياسية للوحدة std::sync::atomic للمزيد من التفاصيل، من الكافي الآن معرفة أن الأنواع الذرية تعمل مثل الأنواع الأولية ولكن من الآمن مشاركتها عبر الخيوط.

قد تتساءل عن عدم كون جميع الأنواع الأولية ذرية ولماذا أنواع المكتبات القياسية غير مُطبّقة لاستخدام <Arc<T افتراضيًا، والسبب هنا هو أن سلامة الخيوط تأتي مع ضريبة أداء تريد دفعها فقط عندما تحتاج إليها حقًا. إذا كنت تجري عمليات على القيم ضمن خيط واحد فيمكن لشيفرتك البرمجية أن تُنفَّذ بصورةٍ أسرع إذا لم تكن بحاجة لفرض الضمانات التي تقدمها الأنواع الذرية.

بالعودة إلى مثالنا: للنوعين <Arc<T و <Rc<T الواجهة البرمجية API ذاتها لذلك نصحّح برنامجنا عن طريق تغيير سطر use واستدعاء new وكذلك استدعاء clone. نستطيع أخيرًا تصريف الشيفرة البرمجية في الشيفرة 15 وتنفيذها.

اسم الملف: src/main.rs

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

الشيفرة 15: استخدام <Arc<T لتغليف <Mutex<T بحيث نستطيع مشاركة الملكية عبر خيوط متعددة

يكون خرج الشيفرة البرمجية السابقة على النحو التالي:

Result: 10

وأخيرًا نجحنا، فقد عدَدنا من 0 إلى 10، وعلى الرغم من أن هذا قد لا يبدو رائعًا جدًا، إلا أنه علّمنا الكثير عن <Mutex<T وأمان الخيط. يمكنك أيضًا استخدام هيكل هذا البرنامج لإجراء عمليات أكثر تعقيدًا من مجرد زيادة العداد، ويمكننا باستخدام هذه الإستراتيجية تقسيم عملية حسابية إلى أجزاء مستقلة وتقسيم تلك الأجزاء عبر خيوط ثم استخدام <Mutex<T لجعل كل خيط يحدّث النتيجة النهائية بجزئه.

لاحظ أنه إذا كنت تنفّذ عمليات عددية بسيطة فهناك أنواع أبسط من أنواع <Mutex<T التي توفرها وحدة المكتبة القياسية std::sync::atomic، إذ توفر هذه الأنواع وصولًا ذريًا آمنًا ومتزامنًا للأنواع الأولية، وقد اخترنا استخدام <Mutex<T مع نوع أولي لهذا المثال حتى نتمكن من التركيز على كيفية عمل <Mutex<T.

التشابه بين <RefCell<T>/Rc<T و <Mutex<T>/Arc<T

ربما لاحظت أن counter ثابت immutable ولكن يمكننا الحصول على مرجع متغيّر للقيمة الموجودة داخله، هذا يعني أن <Mutex<T يوفر قابلية تغيير داخلية كما تفعله عائلة Cell. نستخدم <Mutex<T بنفس الطريقة التي استخدمنا بها <RefCell<T سابقًا في مقال المؤشر الذكي Refcell‎ ونمط قابلية التغيير الداخلي interior mutability في لغة رست Rust للسماح لنا بتغيير المحتويات داخل <Rc<T وذلك لتغيير المحتويات داخل Arc<T>‎.

هناك شيء آخر يجب ملاحظته ألا وهو أن رست لا يمكنها حمايتك من جميع أنواع الأخطاء المنطقية عند استخدام <Mutex<T. تذكر سابقًا في مقال المؤشر Rc‎ الذكي واستخدامه للإشارة إلى عدد المراجع في لغة رست Rust أن استخدام <Rc<T يأتي مع خطر إنشاء دورات مرجعية reference cycle، إذ تشير قيمتان <Rc<T إلى بعضهما بعضًا مما يتسبب في حدوث تسرب في الذاكرة. وبالمثل يأتي تطبيق <Mutex<T مع خطر خلق حالات مستعصية deadlocks، إذ تحدث هذه الحالات عندما تحتاج عملية ما إلى الحصول على مرجعين وحصل كل منهما على أحد الأقفال مما يتسبب في انتظارهما لبعضهما بعضًا إلى الأبد.

إذا أثارت الحالات المستعصية اهتمامك وأردت رؤيتها عمليًا فحاول إنشاء برنامج رست به حالة مستعصية، ثم ابحث عن استراتيجيات التخفيف من الحالات المستعصية بالنسبة لكائنات المزامنة في أي لغة ونفذها في رست. يوفر توثيق واجهة المكتبة القياسية البرمجية لـ<Mutex<T و MutexGuard معلومات مفيدة.

سنكمل هذا المقال بالحديث عن السمتين Send و Sync وكيف يمكننا استخدامها مع الأنواع المخصصة.

التزامن الموسع مع السمة Sync والسمة Send

من المثير للاهتمام أن لغة رست تحتوي على عدد قليل جدًا من ميزات التزامن، إذ تعد كل ميزة تزامن تحدثنا عنها حتى الآن جزءًا من المكتبة القياسية standard library وليست اللغة. لا تقتصر خياراتك للتعامل مع التزامن على اللغة أو المكتبة القياسية فيمكنك كتابة ميزات التزامن الخاصة بك أو استخدام تلك المكتوبة من قبل الآخرين.

ومع ذلك، ضُمِّنَ مفهومان للتزامن في اللغة: سمتَا std::marker وهما Sync و Send.

السماح بنقل الملكية بين الخيوط عن طريق Send

تشير الكلمة المفتاحية للسمة Send إلى أنه يمكن نقل ملكية القيم من النوع الذي ينفّذ Send بين الخيوط، ويطبّق كل نوع في رست تقريبًا السمة Send، ولكن هناك بعض الاستثناءات بما في ذلك <Rc<T إذ لا يمكن تطبيق السمة Send عليه لأنه إذا استنسخت قيمة <Rc<T وحاولت نقل ملكية الاستنساخ إلى خيط آخر فقد يحدّث كلا الخيطين عدد المراجع reference count في الوقت ذاته. لهذا السبب تُنفَّذ <Rc<T للاستخدام في المواقف أحادية الخيط حيث لا تريد دفع غرامة الأداء الآمن.

لذلك يؤكد نظام نوع رست وحدود السمات trait bounds أنه لا يمكنك أبدًا إرسال قيمة <Rc<T بطريق الخطأ عبر الخيوط بصورةٍ غير آمنة. عندما حاولنا فعل ذلك في الشيفرة 14 سابقًا حصلنا على الخطأ:

the trait Send is not implemented for Rc<Mutex<i32>>

وعندما استبدلنا النوع بالنوع <Arc<T الذي يطبّق السمة Send صُرِّفَت الشيفرة البرمجية بنجاح.

أي نوع مكون بالكامل من أنواع Send يُميَّز تلقائيًا على أنه Send أيضًا، وتطبّق جميع الأنواع الأولية primitive types تقريبًا السمة Send بغض النظر عن المؤشرات الأولية primitive pointers التي سنناقشها لاحقًا في.

السماح بالوصول لخيوط متعددة باستخدام السمة Sync

تشير الكلمة المفتاحية للسمة Sync إلى أنه من الآمن للنوع المطبّق للسمة Sync أن يُشار إليه من خيوط متعددة، بمعنى آخر أي نوع T يطبّق السمة Sync إذا كان T& (مرجع غير قابل للتغيير إلى T) يطبّق Send أيضًا، مما يعني أنه يمكن إرسال المرجع بأمان إلى خيط آخر، وبصورةٍ مشابهة للسمة Send فإن الأنواع الأولية تطبّق السمة Sync والأنواع المكونة كاملًا من أنواع تطبّق السمة Sync هي أيضًا أنواع تطبّق Sync.

لا يطبّق المؤشر الذكي <Rc<T أيضًا السمة Sync للأسباب ذاتها التي تجعل منه غير قابل لتطبيق السمة Send، كما أن النمط <RefCell<T (الذي تحدثنا عنه سابقًا وعائلة الأنواع المرتبطة بالنوع <Cell<T لا تطبّق السمة Sync. يعدّ تنفيذ فحص الاستعارة الذي تفعله <RefCell<T في وقت التنفيذ غير آمن للخيط. يطبّق المؤشر الذكي <Mutex<T السمة Sync ويمكن استخدامه لمشاركة الوصول مع خيوط متعددة كما رأيت سابقًا في قسم "مشاركة <Mutex<T بين خيوط متعددة".

تطبيق السمتين Send و Sync يدويا غير آمن

بما أن الأنواع المكونة من الأنواع التي تطبّق السمتين Send وSync هي أنواع تطبّق السمتين Send و Sync تلقائيًا، فلا يتوجب علينا تطبيق هاتين السمتين يدويًا؛ وبصفتهما سمتان علّامة marker traits، فهما سمتان لا تحتويان أيّ توابع تطبّقها، وهما مفيدتان فقط لتعزيز الثوابت المرتبطة بالمزامنة.

يشمل تنفيذ هاتان السمتان يدويًا تنفيذ شيفرة رست غير آمنة، وسنتحدث عن استعمال شيفرة رست غير آمنة لاحقًا، ومن الكافي الآن معرفتك أن بناء أنواع متزامنة جديدة غير مؤلفة من أجزاء Send و Sync يتطلّب تفكيرًا حذرًا للمحافظة على ضمانات الأمان في لغة رست. يحتوي الكتاب “The Rustonomicon” معلومات أكثر عن هذه الضمانات وكيف يمكن التمسك بها.

ترجمة -وبتصرف- لقسم من الفصل Fearless Concurrency من كتاب The Rust Programming Language.

اقرأ أيضًا:


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...