[این مقاله در سطح «پیشرفته» و نیازمند آشنایی خواننده با زبان PHP و نرمافزار Redis و مفاهیم «شرایط رقابتی» و «Shared Object» است.]
کنترل دسترسی چند پروسه به منابع مشترک یا shared object ها از مسائلی است که روزانه در زندگی واقعی برنامهنویسان رخ میدهد. مدیریت این گونه از مسائل اهمیت بسیار زیادی در پیادهسازی نرمافزارها و سرویسهای توزیعشده دارد. پیامد مدیریت نادرست این مسأله منجر به ایجاد شرایط رقابتی و بروز باگهای غیرقابل پیگیری در برنامه میشود. آشفتهبازار شرایط رقابتی چیزی شبیه به رقابت سنتی بو-تااوشی در ژاپن است در آن هر فرد از تیم رقیب، به هر طریق و وسیلهای، موظف است دکل میانهی میدان را به زیر بکشد.
برنامهنویسان PHP شاید از معدود برنامهنویسانی باشند که کمتر با این گونه مشکلات درگیر هستند و به آن فکر میکنند. طراحی زبان PHP به گونهای است که به هر فرد به صورت جداگانه سرویسدهی کند و اشتراک دادهها بین متقاضیان سرویس از طریق اطلاعات ذخیرهشده در پایگاه دادهی MySQL انجام شود. پایگاههای دادهای مانند MySQL کنترل دسترسی پروسهها را به صورت درونی انجام میدهند و برنامهنویس نیازی به نگرانی در مورد آن ندارد. اما بدون ذکر مصداق و بنا بر تجربهی شخصی نگارنده، حتی در PHP هم ممکن است شرایطی رخ دهد که چند پروسهی PHP نیاز به دسترسی به دادههای مشترکی داشته باشند که ذاتاً هیچ کنترلی برای دسترسی به آنها وجود ندارد.
متأسفانه زبان PHP ذاتاً دارای سازوکاری برای استفاده از راه حلهای معمول و منطقی امروزی، مانند semaphore و mutex، را ندارد. اما این به آن معنی نیست که چنین کاری در PHP امکانپذیر نباشد. در ادامهی این مقاله سعی شده است تا چند نمونه از راهکارهای موجود در PHP برای پرهیز از شرایط رقابتی و مزایا و معایب آنها ارائه شود.
قفل پرونده
یکی از سادهترین راهکارهای موجود برای کنترل دسترسی استفاده از قفل پرونده است. این روش قدمتی دیرینه دارد و استفاده از آن تنها محدود به زبان PHP نیست. تمام سیستمعاملهای امروزی سازوکاری برای کنترل دسترسی پروسهها به پروندهها دارند، تا با محدود کردن دسترسیهای خواندن و نوشتن به پروندهها، از خراب شدن اطلاعات آنها جلوگیری کنند.
فارغ از اینکه از چه سیستمعاملی استفاده میکنید، میتوانید با استفاده از تابع flock
در PHP از این قابلیت استفاده کنید. برای این منظور نیاز دارید تا یک پرونده را با استفاده از fopen
باز و سپس در صورت نیاز به در دست گرفتن قفل، با استفاده از تابع flock
آن را قفل کنید. اگر بخواهید پروندهای را قفل کنید که پیش از شما قفل شده است، این تابع منتظر میماند تا قفل پرونده رها شود. بدین طریق، اطمینان حاصل میگردد که در هر زمان تنها یکی از پروسههای PHP قفل پرونده را در دست داشته باشد. برای رها کردن قفل میتوان مجدداً تابع flock
را فراخوانی کرد، اما PHP به صورت خودکار با بسته شدن پرونده با فراخوانی fclose
یا به اتمام رسیدن اجرای کد این کار را برای شما خواهد کرد. کد زیر نمونهای از استفاده از قفل پرونده را نشان میدهد.
<?php // Before locking, file must be opened. $fp = fopen("/tmp/lock", "w+"); if (flock($fp, LOCK_EX)) { // - Try to exclusively lock the file. ftruncate($fp, 0); // - Clear file contents. fwrite($fp, 'LOCKED'); // - State that the file is locked and fflush($fp); // flush the write buffer. /* TODO Some stuff with the shared resources that needed to be done exclusively. */ ftruncate($fp, 0); // - Clear file contents again. fwrite($fp, 'FREE'); // - State that the file is unlocked flush($fp); // flush the write buffer. flock($fp, LOCK_UN); // - Release the file lock. } else { die 'Cannot capture the lock!'; }
این روش علیرغم عمومیت و سادگیاش دارای معایبی نیز هست. از جمله اینکه هنگام باز کردن یک پرونده و قفل کردن آن باید دسترسی پروسهی PHP در سیستمعامل مهیا شود. اما مهمترین ایراد این روش آن است که قفل پرونده تنها میان پروسههای PHP معتبر است؛ یعنی اگر پروسهای خارجی در سیستمعامل به پرونده دسترسی یابد، میتواند آزادانه اطلاعات آن را تغییر دهد.
قفل پایگاه داده
پایگاههای دادهای مانند MySQL دارای سازوکاری برای ایجاد قفل هستند. قفل کردن و رها کردن قفل در MySQL بهترتیب توسط دستورهای GET_LOCK
و RELEASE_LOCK
انجام میشود. قفلهای ایجاد شده بدین روش دارای نام هستند و پروسههای دیگر به کمک نام میتوانند به آن دسترسی داشته باشند. کد زیر نمونهای از استفاده از قفل MySQL را نشان میدهد.
<?php // MySQL Connection Info $dbhost = 'localhost'; $dbuser = 'root'; $dbpass = 'password'; $dbname = 'my_db'; // Name of the lock to be captured. $dblock = 'my_lock'; // Establish a connection to database. $conn = mysql_connect($dbhost, $dbuser, $dbpass) or die 'Connection failed.'; mysql_select_db($dbname); function getDbLock() { $sql = "GET_LOCK($dblock);"; mysql_query($sql); } function releaseDbLock() { $sql = "RELEASE_LOCK($dblock);"; mysql_query($sql); } getDbLock(); // - Capture the lock. This may fail, so // further checks for success must be done. /* TODO Add jobs needed to be done exclusively here. */ releaseLock(); // - Release the lock for other processes.
برای استفاده از این قفل نیاز به داشتن ارتباط با سرویسدهندهی MySQL است. این روش، روشی مطمئن برای کنترل دسترسی است، اما باید به دو نکته توجه کرد: ۱) قفلهای MySQL که توسط GET_LOCK
گرفته میشوند، با پایان یافتن ارتباط با MySQL و به صورت خودکار رها نمیشوند، بنابراین، باید سازوکارهای کافی برای اطمینان از رها شدن قفل در نظر گرفته شود. در غیر این صورت، پروسههای دیگر دچار بنبست میشوند. ۲) نام کاربری مورد استفاده برای برقراری ارتباط با MySQL باید دارای سطوح دسترسی کافی برای استفاده از دستورهای GET_LOCK
و RELEASE_LOCK
را داشته باشد.
پیادهسازی قفل در Redis
بستهی نرمافزار Redis مدتهاست که به عنوان یک پایگاه دادهی درحافظه توسط برنامهنویسان PHP مورد استفاده قرار میگیرد. لیکن بسیاری از امکانات آن برای کاربرانش ناشناخته مانده است. یکی از این امکانات، انجام دستورات متعدد به صورت اتمی است. این کار در Redis توسط سه دستور MULTI
و EXEC
و WATCH
انجام میگردد. به کمک این دستورها میتوان تابع اتمی compareAndSwap
را در PHP به شکل زیر پیادهسازی کرد.
function compareAndSwap($key, $expected, $value) { // Use Predis library for Redis interaction. $redis = new Predis\Client(); // This has to be done with trial and error. while (true) { // Watch if $key is not changed during this operation. $redis->watch($key); $current = $redis->get($key); // Swap is done only when we have expected value in hand. if (!isset($key) || $current == $expected) { $redis->multi(); // Start an atomic operation. $redis->set($key, $value); // Command to be done atomically. $result = $redis->exec(); // Execute command. // If exec command succeeds, returns a non-null result, // and it fails only if watch says the value has been changed // by another process during this operation. if ($result !== null) break; } } }
تابع compareAndSwap
سه ورودی را از کاربر میگیرد: یک کلید Redis که میخواهیم اطلاعات موجود در آن را تغییر دهیم، یک مقدار مورد انتظار که برای مقایسه به کار میرود، و یک مقدار که باید به عنوان مقدار جدید کلید قرار گیرد. این تابع ابتدا مقدار موجود در کلید را با مقدار مورد انتظار مقایسه، و در صورت برابری، مقدار موجود در کلید را با مقدار جدید معاوضه میکند. اکنون با در دست داشتن تابع compareAndSwap
میتوان عملکرد قفل را به صورت زیر پیادهسازی کرد.
define('MY_LOCK', 'my:lock'); // Redis key which holds lock. define('LOCKED', 'locked'); // Locked-state value. define('FREE', 'free'); // Released-state value. function lock() { // Put lock in locked state, if released. compareAndSwap(MY_LOCK, FREE, LOCK); } function release() { // Put lock in released state, if locked. compareAndSwap(MY_LOCK, LOCK, FREE); }
این روش قفل کردن بهدلیل کارایی بسیار بالای Redis بسیار سریع و قابل اطمینان است. این روش محدودیتهای استفاده از قفل پایگاه داده را نیز ندارد. اما همچنان یک مشکل باقیست: در صورت متوقف شدن برنامه، قفل به صورت خودکار رها نخواهد شد و این امر میتواند موجب قرار گرفتن تمام پروسهها در بنبست شود. برای حل این مشکل، کافی است برای کلیدی که قفل در آن قرار دارد یک طول عمر یا ttl
تعریف کنیم. با منقضی شدن طول عمر کلید، Redis به صورت خودکار آن را حذف خواهد کرد. باید دقت داشت که این زمان باید بهاندازهی کافی بزرگ باشد تا کلید پیش از رها شدن قفل توسط پروسه، حذف نگردد. با توجه به این موضوع، تابع compareAndSwap
را میتوان به صورت زیر بازنویسی کرد.
define('MAX_LOCK_TTL', 60); // 1-Min TTL (Time to Live) function compareAndSwap($key, $expected, $value) { // Use Predis library for Redis interaction. $redis = new Predis\Client(); // This has to be done with trial and error. while (true) { // Watch if $key is not changed during this operation. $redis->watch($key); $current = $redis->get($key); // Swap is done only when we have expected value in hand. if (!isset($current) || $current == $expected) { $redis->multi(); // Start an atomic operation. $redis->set($key, $value, MAX_LOCK_TTL); $result = $redis->exec(); // Execute command. // If exec command succeeds, returns a non-null result, // and it fails only if watch says the value has been changed // by another process during this operation. if ($result !== null) break; } } }