مزايا وعيوب `string` في OpenResty

API7.ai

December 8, 2022

OpenResty (NGINX + Lua)

في المقال السابق، تعرفنا على وظائف الحظر الشائعة في OpenResty، والتي غالبًا ما يتم إساءة استخدامها من قبل المبتدئين. بدءًا من هذا المقال، سنتعمق في جوهر تحسين الأداء، والذي سيتضمن العديد من تقنيات التحسين التي يمكن أن تساعدنا في تحسين أداء كود OpenResty بسرعة، لذا لا تستهين به.

في هذه العملية، نحتاج إلى كتابة المزيد من أكواد الاختبار لتجربة كيفية استخدام هذه التقنيات والتحقق من فعاليتها حتى نتمكن من استخدامها بشكل جيد.

خلف الكواليس لنصائح تحسين الأداء

تقنيات التحسين هي جزء من "الجانب العملي"، لذا قبل أن نفعل ذلك، دعنا نتحدث عن "النظرية" وراء التحسين.

ستتغير طرق تحسين الأداء مع تطور LuaJIT وOpenResty. قد يتم تحسين بعض الطرق مباشرة بواسطة التقنيات الأساسية ولا تحتاج إلى إتقانها؛ في نفس الوقت، ستظهر تقنيات تحسين جديدة. لذلك، من الأهمية بمكان إتقان المفهوم الثابت وراء هذه التقنيات.

دعنا نلقي نظرة على بعض الأفكار الرئيسية حول الأداء في برمجة OpenResty.

النظرية 1: يجب أن تكون معالجة الطلبات قصيرة وبسيطة وسريعة

OpenResty هو خادم ويب، لذا غالبًا ما يتعامل مع 1000+، 10000+، أو حتى 100000+ طلب عميل في نفس الوقت. لذلك، لتحقيق أعلى أداء عام، يجب أن نضمن معالجة الطلبات الفردية بسرعة واستعادة الموارد المختلفة، مثل الذاكرة.

  • "القصيرة" هنا تعني أن دورة حياة الطلب يجب أن تكون قصيرة حتى لا تشغل الموارد لفترة طويلة دون إطلاقها؛ حتى بالنسبة للاتصالات الطويلة، يجب تعيين حد زمني أو عدد من الطلبات لإطلاق الموارد بانتظام.
  • "البسيطة" الثانية تشير إلى القيام بشيء واحد فقط في واجهة برمجة التطبيقات (API). قم بتقسيم المنطق التجاري المعقد إلى واجهات برمجة تطبيقات متعددة وحافظ على الكود بسيطًا.
  • أخيرًا، "السريعة" تعني عدم حظر الخيط الرئيسي وعدم تشغيل الكثير من عمليات وحدة المعالجة المركزية (CPU). حتى إذا كان عليك القيام بذلك، لا تنسَ العمل مع الطرق الأخرى التي قدمناها في المقال السابق.

هذا الاعتبار المعماري ليس مناسبًا فقط لـ OpenResty، ولكن أيضًا لتطوير اللغات والمنصات الأخرى، لذا آمل أن تفهمه وتفكر فيه بعناية.

النظرية 2: تجنب إنشاء بيانات وسيطة

تجنب البيانات غير المفيدة في العملية الوسيطة يمكن اعتباره النظرية الأكثر هيمنة في تحسين برمجة OpenResty. دعنا نلقي نظرة على مثال صغير لشرح البيانات غير المفيدة في العملية الوسيطة.

$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'

في مقتطف الكود هذا، قمنا بعدة عمليات دمج على المتغير s للحصول على النتيجة hello world!. ولكن فقط الحالة النهائية لـ s وهي hello world! هي المفيدة. القيمة الأولية لـ s والتعيينات الوسيطة هي جميعها بيانات وسيطة يجب إنشاء أقل قدر ممكن منها.

السبب هو أن هذه البيانات المؤقتة ستجلب خسائر في الأداء بسبب التهيئة وGC (جمع القمامة). لا تستهين بهذه الخسائر؛ إذا ظهرت هذه في كود ساخن مثل الحلقات، سيكون الأداء منخفضًا بشكل واضح. سأشرح هذا لاحقًا بمثال عن النصوص.

النصوص (strings) غير قابلة للتغيير

الآن، نعود إلى موضوع هذا المقال، النصوص (strings). هنا، أؤكد على حقيقة أن النصوص غير قابلة للتغيير في Lua.

بالطبع، هذا لا يعني أن النصوص لا يمكن دمجها أو تعديلها، ولكن عندما نقوم بتعديل نص، لا نقوم بتغيير النص الأصلي ولكن ننشئ كائن نص جديد ونغير المرجع إلى النص. لذا بشكل طبيعي، إذا لم يكن للنص الأصلي أي مراجع أخرى، سيتم استعادته بواسطة GC (جمع القمامة) في Lua.

الفائدة الواضحة للنصوص غير القابلة للتغيير هي توفير الذاكرة. بهذه الطريقة، سيكون هناك نسخة واحدة فقط من النص نفسه في الذاكرة، وسوف تشير المتغيرات المختلفة إلى نفس العنوان في الذاكرة.

عيب هذا التصميم هو أنه عندما يتعلق الأمر بإضافة واستعادة النصوص، في كل مرة تضيف نصًا، يجب على LuaJIT استدعاء lj_str_new للاستعلام عما إذا كان النص موجودًا بالفعل؛ إذا لم يكن موجودًا، يجب إنشاء نص جديد. إذا قمت بذلك بشكل متكرر، سيكون له تأثير كبير على الأداء.

دعنا نلقي نظرة على مثال ملموس لعملية دمج نصوص مثل المثال الموجود في العديد من مشاريع OpenResty المفتوحة المصدر.

$ resty -e 'local begin = ngx.now()
local s = ""
-- حلقة `for`، باستخدام `..` لإجراء دمج النصوص
for i = 1, 100000 do
    s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'

ما يفعله كود العينة هذا هو إجراء 100000 عملية دمج نصوص على المتغير s وطباعة وقت التشغيل. على الرغم من أن المثال مبالغ فيه قليلاً، إلا أنه يعطي فكرة جيدة عن الفرق بين الأداء قبل وبعد التحسين. بدون تحسين، يعمل هذا الكود لمدة 0.4 ثانية على جهاز الكمبيوتر المحمول الخاص بي، وهو ما يزال بطيئًا نسبيًا. إذن كيف يجب علينا تحسينه؟

في المقالات السابقة، تم تقديم الإجابة، وهي استخدام table للقيام بطبقة من التغليف، وإزالة جميع النصوص الوسيطة المؤقتة والاحتفاظ فقط بالبيانات الأصلية والنتيجة النهائية. دعنا نلقي نظرة على تنفيذ الكود الملموس.

$ resty -e 'local begin = ngx.now()
local t = {}
-- حلقة `for` تستخدم مصفوفة لحفظ النص، مع حساب طول المصفوفة في كل مرة
for i = 1, 100000 do
    t[#t + 1] = "a"
end
-- دمج النصوص باستخدام دالة `concat` للمصفوفات
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

يمكننا أن نرى أن هذا الكود يحفظ كل نص بدوره في table، ويتم تحديد الفهرس بواسطة #t + 1، أي الطول الحالي لـ table زائد 1. أخيرًا، يتم استخدام دالة table.concat لدمج كل عنصر في المصفوفة. هذا يتخطى بشكل طبيعي جميع النصوص المؤقتة ويتجنب 100000 استدعاء لـ lj_str_new وGC.

كان هذا تحليلنا للكود، ولكن كيف يعمل التحسين؟ الكود المحسن يستغرق فقط 0.007 ثانية، مما يعني تحسنًا في الأداء بأكثر من 50 مرة. في مشروع فعلي، قد يكون التحسن في الأداء أكثر وضوحًا لأنه في هذا المثال، أضفنا حرفًا واحدًا فقط a في كل مرة.

ما الفرق في الأداء إذا كان النص الجديد بطول 10x a؟

هل 0.007 ثانية من الكود جيدة بما يكفي لعملية التحسين الخاصة بنا؟ لا، لا يزال يمكن تحسينها. دعنا نعدل سطرًا آخر من الكود ونرى النتيجة.

$ resty -e 'local begin = ngx.now()
local t = {}
-- حلقة `for`، باستخدام مصفوفة لحفظ النص، مع الحفاظ على طول المصفوفة نفسه
for i = 1, 100000 do
    t[i] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

هذه المرة، قمنا بتغيير t[#t + 1] = "a" إلى t[i] = "a"، وبسطر واحد فقط من الكود، يمكننا تجنب 100000 استدعاء دالة للحصول على طول المصفوفة. تذكر العملية للحصول على طول المصفوفة التي ذكرناها في قسم table سابقًا؟ لها تعقيد زمني O(n)، وهي عملية مكلفة نسبيًا. لذا، هنا نحن ببساطة نحافظ على الفهرس الخاص بنا لتجاوز عملية الحصول على طول المصفوفة. كما يقول المثل، إذا لم تستطع تحمل الفوضى، يمكنك تجنبها.

بالطبع، هذه طريقة أبسط للكتابة. الكود التالي يوضح بشكل أكثر وضوحًا كيفية الحفاظ على الفهرس الخاص بنا.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

تقليل النصوص المؤقتة الأخرى

الأخطاء التي تحدثنا عنها للتو، النصوص المؤقتة الناتجة عن دمج النصوص، واضحة. مع بعض التذكيرات من كود العينة أعلاه، أعتقد أننا لن نرتكب أخطاء مماثلة مرة أخرى. ومع ذلك، هناك بعض النصوص المؤقتة الأكثر خفية التي يتم إنشاؤها في OpenResty، والتي يصعب اكتشافها بشكل أكبر. على سبيل المثال، دالة معالجة النصوص التي سنناقشها أدناه تُستخدم غالبًا. هل يمكنك تخيل أنها أيضًا تولد نصوصًا مؤقتة؟

كما نعلم، دالة string.sub تقوم بقص جزء محدد من النص. كما ذكرنا سابقًا، النصوص في Lua غير قابلة للتغيير، لذا فإن قص نص جديد يتضمن lj_str_new وعمليات GC اللاحقة.

resty -e 'print(string.sub("abcd", 1, 1))'

وظيفة الكود أعلاه هي استخراج الحرف الأول من النص وطباعته. بشكل طبيعي، سيولد هذا نصًا مؤقتًا. هل هناك طريقة أفضل لتحقيق نفس التأثير؟

resty -e 'print(string.char(string.byte("abcd")))'

بالطبع. بالنظر إلى هذا الكود، نستخدم أولاً string.byte للحصول على الرمز العددي للحرف الأول، ثم نستخدم string.char لتحويل الرقم إلى الحرف المقابل. هذه العملية لا تولد أي نصوص مؤقتة. لذلك، من الأكثر كفاءة استخدام string.byte لإجراء المسح والتحليل المتعلق بالنصوص.

الاستفادة من دعم SDK لنوع table

بعد تعلم كيفية تقليل النصوص المؤقتة، هل أنت متحمس لتجربتها؟ إذن، يمكننا أخذ نتيجة كود العينة أعلاه وإخراجها إلى العميل كمحتوى جسم الاستجابة. في هذه المرحلة، يمكنك التوقف ومحاولة كتابة هذا الكود بنفسك أولاً.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'

إذا تمكنت من كتابة هذا الكود، فأنت بالفعل متقدم على معظم مطوري OpenResty. واجهة برمجة التطبيقات Lua في OpenResty تأخذ بالفعل في الاعتبار استخدام tables لدمج النصوص، لذا في ngx.say، ngx.print، ngx.log، cosocket:send، وغيرها من واجهات برمجة التطبيقات التي قد تأخذ الكثير من النصوص، فإنها تقبل ليس فقط string كمعامل، ولكن أيضًا table كمعامل.

resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
ngx.say(t)
'

في مقتطف الكود الأخير هذا، قمنا بحذف local response = table.concat(t, "")، خطوة دمج النصوص، وتمرير table مباشرة إلى ngx.say. هذا ينقل مهمة دمج النصوص من مستوى Lua إلى مستوى C، مما يتجنب عملية بحث وإنشاء وGC أخرى للنصوص. بالنسبة للنصوص الطويلة، هذا يمثل مكسبًا كبيرًا آخر في الأداء.

الخلاصة

بعد قراءة هذا المقال، يمكننا أن نرى أن الكثير من تحسين أداء OpenResty يتعامل مع تفاصيل مختلفة. لذلك، نحتاج إلى معرفة LuaJIT وواجهة برمجة التطبيقات Lua في OpenResty جيدًا لتحقيق أفضل أداء. هذا أيضًا يذكرنا بأنه إذا نسينا المحتوى السابق، يجب علينا مراجعته وتعزيزه في الوقت المناسب.

أخيرًا، فكر في مشكلة: اكتب النصوص hello، world، و! إلى سجل الأخطاء. هل يمكننا كتابة كود عينة بدون دمج النصوص؟

أيضًا، لا تنسَ السؤال الآخر في النص. ما الفرق في الأداء في الكود التالي إذا كانت النصوص الجديدة بطول 10x a؟

$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
    t[#t + 1] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

أنت أيضًا مرحب بك لمشاركة هذا المقال مع أصدقائك للتعلم والتبادل.