Memory Mapped Files

یکشنبه, ۲ تیر ۱۴۰۰

Win32
Memory Mapped Files
C#

مقدمه

بهانه‌ی نوشتن این پست برای من امکان جایگزینیِ IPC با Memory Mapped File بود، گرچه با وجود سرعت بالای آن به‌نظر بهترین راه‌حل برای این جایگزینی نیست. البته Memory Mapped File کاربردهای دیگری هم دارد که چه‌بسا مهم‌ترند، بنابراین دانستنِ آن بی‌فایده نیست. همان‌طور که در ادامه خواهید دید استفاده از این قابلیتِ ویندوز در dotnet به هیچ وجه کار سختی نیست، اما فهمِ چگونگیِ عملکردِ سیستم‌عامل در لایه‌های پایین‌تر باعث درکِ درستِ موضوع می‌شود. به همین خاطر متن با معرفی مختصری از معماری حافظه در ویندوز آغاز می‌شود و در ادامه به توضیحِ Memory Mapped Fileها می‌پردازد و در نهایت با چند مثال و کاربردِ مختلف به زبان #C خاتمه می‌یابد.

سیستم‌عامل و مدیریت حافظه

سیستم‌عامل طبق وظیفه‌اش قسمتی از حافظه را در اختیار پردازه (Process) قرار می‌دهد. پردازه هم خود می‌داند از این حافظه چگونه استفاده کند که این آگاهی گاه توسط برنامه‌نویس و گاه توسط زبان، Runtime یا Framework فراهم می‌شود. برای مثال سیستم‌عامل 4 گیگابایت حافظه در اختیار یک پردازه می‌گزارد اما اینکه طول عمر متغیرها در حافظه چقدر باشد و موضوعاتی از این قبیل، به عهده‌ی خود پردازه است. اما در بین امکانات مربوط به حافظه که از طرف سیستم‌عامل - مشخصا ویندوز - ارائه می‌شود، یکی می‌تواند در برنامه‌های سطح بالا همچنان کاربردی باشد: Memory Mapped File. ‏Memory Mapped Fileها یکی از امکاناتِ سیستمیِ ویندوز است که مبتنی بر ساختارِ مدیریت حافظه‌ی ویندوز ارائه شده است و کاربردهای متفاوتی دارد. مثلا کار با فایل‌های بزرگ یا اشتراک داده بین پردازه‌های مختلف.

همان طور که می‌دانیم در ویندوز واحد تخصیص منابع پردازه (Process) است. بنابراین حافظه در اختیار پردازه‌ها قرار می‌گیرد و هر پردازه تنها مجاز به دسترسی به حافظه‌ی خودش است. در ابتدایی‌ترین حالت، مدیریت حافظه به این شکل انجام می‌شود:

در شکل بالا سیستم یک حافظه‌ی RAM با ظرفیت 1MB دارد. در این سیستم به هر پردازه 256KB حافظه‌ی فیزیکی اختصاص داده می‌شود و در حال حاضر 512KB از کل حافظه آزاد است. بدیهی است که در چنین شرایطی حداکثر 4 پردازه امکان فعالیتِ هم زمان دارند - با صرف‌نظر از حافظه‌ای که خود سیستم‌عامل برای بقا نیاز دارد. بنابراین برای باز کردنِ پردازه‌ی پنجم حتما باید به زندگی یکی از چهار پردازه‌ی باز پایان داد چراکه در سیستم‌عامل‌های قدیمی ظرفیتِ حافظه‌ی فیزیکی با ظرفیتِ RAM برابری می‌کرد و این واقعیت محدودیت‌های زیادی را باعث می‌شد! حتی با ظرفیت‌های امروزی میزانِ نیازِ پردازه‌ها در یک سیستم می‌تواند به مراتب بیشتر از RAM باشد. چنین مشکلاتی طراحان سیستم‌عامل را وادار به استفاده از مدلِ دیگری از مدیریت حافظه کرد که فهم آن پیش نیاز فهمِ درستِ Memory Mapped File است: حافظه‌ی مجازی.

حافظه‌ی مجازی

جمله‌ای قدیمی هست که "وقتی مامان نباشه باید با زن بابا ساخت!"

وقتی حافظه‌ی اصلی به اندازه‌ی کافی موجود نبود باید از حافظه‌ی دیگری استفاده کرد. مثل دیسک، یا به طور کل حافظه‌ی ثانویه. بنابراین تمامِ هنرِ سیستم‌عامل در مدیریت حافظه این است که به گونه‌ای از دیسک به عنوان حافظه‌ای پشتیبان استفاده کند که بشود بیشتر از آنچه ممکن است در RAM ذخیره کرد. چگونگی انجام این کار زیاد دور از تصور نیست؛ با جابجایی محتویاتِ موجود در RAM به دیسک، می‌توان فضای آن را آزاد و در آن اطلاعاتِ جدیدی ذخیره کرد. اینکه این محتویات دقیقا چه زمانی در دیسک نوشته می‌شود در حوصله‌ی این بحث نیست اما نکته‌ی مهم این است که در روش مدیریت حافظه‌ی مجازی از دیسک به عنوان پشتیبانِ RAM استفاده می‌شود. با استفاده از همین روش یک سیستمِ 32بیتی می‌تواند ظرفیتِ 4گیگابایت و یک سیستمِ 64بیتی 16اگزابایت حافظه‌ی مجازی در اختیار پردازه‌ها قرار دهد. با توجه به ظرفیت RAMها در این روزها شاید 4 گیگابایت آنچنان به چشم نیاید اما امکان تخصیصِ 16اگزابایت به یک پردازه چیز کمی نیست! برای اینکه بهتر متوجه چگونگی رابطه‌ی یک پردازه با فضای آدرسش شویم به جزیات بیشتری اشاره می‌کنم؛ سیستم‌عامل هنگام ایجاد هر پردازه، موجودیتی به نام فضایِ آدرسِ مجازی (Virtual Address Space) به آن تخصیص می‌دهد. منظور از فضایِ آدرسِ مجازی در حقیقت مجموعه‌ای از آدرس‌هاست. مثل یک آرایه. آرایه‌ای را تصور کنید که مقدار هر عنصرِ آن یک آدرس است. هر کدام از این آدرس‌ها به محلی فیزیکی در RAM به صورت غیرمستقیم اشاره می‌کنند که در ادامه منظور از غیر مستقیم را توضیح خواهم داد. شکل زیر یک فضای آدرس مجازی را نشان می دهد:

تا اینجا منظور از فضای آدرس به روشنی بیان شد؛ مجموعه آدرس‌هایی که به فضایی در RAM اشاره می‌کنند. اما منظور از مجازی چیست؟ منظور از مجازی این است که هیچ کدام از این آدرس‌ها به محلی واقعی در RAM اشاره نمی‌کنند و قبل از نوشتن/خواندن داده‌ها از این آدرس‌ها ابتدا باید یک نگاشت (Mapping) انجام شود. بنابراین هیچ بعید نیست که فضای آدرس دو پردازه به شکل زیر باشد:

همان‌طور که در شکل بالا مشخص است هر دو پردازه‌ی A و B می توانند آدرسِ 0XCC2 را در فضای آدرسشان داشته باشند و بدون هیچ‌گونه تصادمی داده‌هایشان را داخل حافظه بنویسند/بخوانند. اما این دو آدرس، با وجود مقادیر یکسان، عملا به دو محلِ فیزیکی متفاوت در RAM اشاره می‌کنند. به خاطر اینکه قبل از انجام هر عمل خواندن/نوشتن ابتدا نگاشتی (Mapping) توسط سیستم‌عامل و CPU انجام می‌شود تا آدرس فیزیکی بدست‌آید. بنابراین پردازه اطلاعاتش را در حافظه‌ی مجازی می‌نویسد و در نهایت سیستم‌عامل تنها قسمتی از حافظه‌ی مجازی را که دارای اطلاعاتِ واقعی است به RAM منتقل می‌کند. به جزئیات و چگونگی این انتقال در ادامه می‌پردازیم. بنابراین تا اینجا آنچه بین پردازه و RAM اتفاق می‌افتد مشخص شد. حالا باید به این سوال پاسخ دهیم که اطلاعاتِ داخلِ RAM در کجای دیسک ذخیره می‌شود و به طور فنی‌تر منظور از جمله‌ی "از دیسک به عنوان پشتیبانِ RAM استفاده می‌شود" چیست.

page و pagefile

در مدیریت حافظه به روش مجازی کوچک‌ترین واحد اطلاعات در RAM، ‏page است که قسمتی پیوسته و یک پارچه محسوب می‌شود. اندازه‌ی page در یک سیستم به معماری پردازنده‌ی آن بستگی دارد. سیستم‌عامل برای انتقالِ اطلاعاتِ اضافی از RAM به دیسک و برعکس با pageها سر‌و‌کار دارد. بنابراین وقتی نیاز است تا RAM خالی‌تر شود تا جا برای پردازه‌های دیگر باز شود سیستم‌عامل تعدادی از pageها را روی دیسک می‌نویسد و هنگامی که دوباره به آن pageها نیاز شد آنها را به RAM منتقل می‌کند. نام این فرآیند paging است و محلی از دیسک که pageها در آن نوشته می‌شوند pagefile نام دارد. بنابراین هر بار نخی در پردازه‌ای تقاضای دسترسی به آدرسی از حافظه را دارد سیستم عامل باید بررسی کند که آیا آن page در RAM قرار دارد یا در دیسک (pagingfile) و اگر در دیسک قرار داشته باشد ابتدا باید آن را در RAM لود کند تا قابل استفاده شود. البته که برای نخ/برنامه‌ای که تقاضای خواندن/نوشتن از حافظه را دارد تمام این مراحل پنهان است.

کمی توجه به فرآیند paging لزوم استفاده از آدرس‌های مجازی را روشن‌تر می‌کند؛ هیچ تضمینی وجود ندارد که هنگامِ نیازِ مجدد به pageهای موجود در دیسک، آنها دوباره در همان محلی نوشته شوند که قبلا حضور داشته‌اند. این قابلیت به سیستم‌عامل آزادی کامل در مدیریت حافظه‌ی فیزیکی را می‌دهد. بنابراین با هر بار رفت‌و‌برگشتِ یک page امکان دارد محل جدیدی برای آن در RAM انتخاب شود. با این اوصاف آیا پردازه از تمام این اتفاقات مطلع است؟ باید گفت خیر. از نظر پردازه تمام اطلاعاتش در RAM همواره در یک آدرس ثابت نگهداری می‌شود که همان آدرسِ فضای مجازی است.

چگونگی نوشتن حافظه‌ی مجازی به دیسک

بیشتر فضایِ آدرسِ مجازیِ تخصیص داده شده به یک پردازه هنگام شروع آزاد یا unallocated است. برای استفاده‌ی واقعی از این فضای آدرس باید به آن region تخصیص داد.

پردازه پس از تصمیم برای نوشتن در حافظه ابتدا قسمتی از فضای آدرس را به میزانی که نیاز دارد رزرو می کند. عمل رزرو با تابعِ سیستمیِ VirtualAlloc انجام می‌شود و هدف از آن این است که آدرس مورد نیاز در دفعات بعد توسط پردازه مصرف نشود. به قسمت رزرو شده اصطلاحا یک region می‌گویند. قبل از استفاده‌ی واقعی از یک region ابتدا باید حافظه‌ی فیزیکی به آن اختصاص داد. یعنی map کردن قسمتی از حافظه‌ی فیزیکی به region که به آن commit می‌گویند. commit کردن هم مثل عملِ رزرو با تابعِ VirtualAlloc انجام می‌شود. بعد از commit سیستم قسمتی از pagefile را بر اساس اندازه‌ی region به آن تخصیص می‌دهد. البته توجه کنید که این قسمت همیشه برابر با اندازه‌ی region نیست چرا که اندازه‌ی تخصیص داده شده از pagefile همیشه باید ضریبی از اندازه‌ی page باشد. حال امکانِ نوشتن در حافظه فراهم می‌شود و در نهایت هنگامی که دیگر به آن حافظه نیازی نیست باید آن region را decommit و در پی آن release کرد.

هر بار نخی در پردازه تقاضای دسترسی به آدرسی از حافظه را دارد سیستم‌عامل باید برررسی کند که آیا آن page در RAM قرار دارد یا در دیسک. اگر در دیسک قرار داشته باشد ابتدا باید آن را لود کند تا قابل استفاده شود. با این اوصاف این سوال پیش می آیند که آیا هگام باز کردن یک برنامه امکان دارد محتویاتِ فایلِ exe در paingfile نوشته شود؟

Memory Mapped File

چگونگیِ کارکردِ Virtual Memory با تاکید بر یک جمله‌ی کلیدی تشریح شد؛ سیستم عامل برای پیاده سازیِ حافظه‌ی مجازی از دیسک به عنوان پشتیبانِ RAM استفاده می‌کند. یعنی سیستم‌عامل اطلاعاتِ پردازه را در هر دو حافظه‌ی اصلی و ثانویه قرار می‌دهد تا در صورت لزوم بتواند pageها را از RAM خارج کند تا فضا برای پردازه‌های دیگر آزاد شود. برای روشن‌تر شدن مسئله باید پرسید؛ منظور از اطلاعات پردازه چیست؟ تاکنون منظور فقط اطلاعاتی بود که هنگام اجرا در اثر پردازشِ ورودی ایجاد می‌شد. اما واضح است که اطلاعاتِ پردازه محدود به ورودی‌های آن نیست. کدها و داده‌های موجود در کد هم جزو این اطلاعات هستند. آیا کدهای یک پردازه، یا به طور دقیق‌تر، محتویات فایل اجرایی (exe یا dll) هم به pagefile منتقل می‌شوند؟ بدیهی است که چنین کاری به هیچ وجه کارآمد نیست چراکه نه تنها فایده‌ای ندارد، بلکه نوشتنِ اطلاعات از دیسک (فایلِ exe یا dll) به دیسک (pagefile) باعث از دست رفتنِ زمان و فضای دیسک هم می‌شود!

اطلاعات اولیه‌ی یک پردازه از قبیلِ text. ،‏ bss. و ‏data. ابتدای اجرای برنامه مستقیما از فایلِ مربوطه (exe یا dll) به حافظه‌ی اصلی منتقل می‌شوند. در حقیقت این سناریو دلیلِ پیاده‌سازیِ قابلیتِ Memory Mapped File توسط مایکروسافت است. بنابراین زمانی که یک پردازه برای اولین بار باز می‌شود، سیستم یک region از فضای آدرس را به صورتِ اتوماتیک برای اطلاعاتِ اولیه‌ی آن رزرو و به فایل مربوطه map می‌کند. توجه کنید که در تمام نسخه‌های خانواده‌ی NT اطلاعاتِ پردازه مانندِ انواعِ دیگرِ اطلاعات در قالب pageها نگهداری می‌شوند تنها با این تفاوت که هنگام ذخیره در دیسک به جای قرارگیری در pagefile، در فایلِ exe یا dll جای دارند.

پس از باز شدن پردازه، ویندوز مسئولیت تمام کارهای مربوط به آن را بر عهده دارد؛ از قبیلِ paging، ‏buffering یا caching. برای مثال اگر در اجرای پردازه سیستم‌عامل به دستورِ jump برخورد کند و page ِ مربوطه در حافظه موجود نباشد پردازه متوجه آن نخواهد شد، بلکه سیستم عامل آن را شناسایی و page ِ مربوطه را لود می‌کند.

به طور کل وجود قابلیت Memory Mapped File امکانات زیادی را در سیستم عامل ایجاد می کند؛ مثلا به‌اشتراک‌گزاریِ اطلاعات اولیه‌ی یک برنامه بینِ پردازه‌های باز شده از آن. بنابراین اگر یک برنامه را بیشتر از یک بار باز کنید سیستم‌عامل به اطلاعاتِ اولیه‌ی هر کدام از آن‌ها حافظه‌ی فیزیکیِ مستقلی اختصاص نمی‌دهد. اطلاعاتِ اولیه یک‌بار در حافظه بارگزاری می‌شود و به دفعات بینِ پردازه‌های مختلف به اشتراک گزاشته می‌شود. البته این امکان وجود دارد که یکی از پردازه‌ها نیاز به تغیر مقادیری در اطلاعاتِ اولیه داشته باشد که در آن صورت ویندوز با استفاده از قابلیتِ copy-on-write آن را ممکن می‌کند. امکانی دیگر در ویندوز که بر اساسِ Memory Mapped File محقق می‌شود دسترسیِ غیرمستقیم به فایل‌ها از طریق حافظه‌ی اصلی است. ویندوز به پردازه‌ها این امکان را می‌دهد تا به فایل‌های موجود در دیسک دقیقا همان‌گونه دسترسی داشته باشند که به حافظه‌ی اصلی دسترسی دارند. اگر بخواهم به زبان ++C توضیح دهم، می‌توان گفت، در این روش دسترسی به فایل درست مثل dereference کردنِ یک اشاره‌گر است:

*pMem = 23;

و یا برای خواندن از فایل:

value = *pMem;

در حینِ اجرای تمامِ این دستورات پردازه نسبت به خواندن/نوشتن در فایل آگاه نیست و این هنرِ سیستم‌عامل است تا با ایجادِ یک لایه‌ی پنهان این فرآیند را عملی کند. فقط باید به این نکته توجه کرد که نوشتنِ فیزیکی در دیسک بلافاصله بعد از نوشتن در متغیر رخ نمی‌دهد.

یکی دیگر از امکاناتی که Memory Mapped File فراهم می‌کند امکان تعامل بین پردازه‌هاست. در این کاربرد پای فایلی به طور مستقیم در میان نیست. بنابراین سیستم‌عامل به صورت خودکار از pagefile استفاده می‌کند بنابراین این طور به نظر می‌رسد که اصلا فایلی در فرآیند درگیر نیست. تعامل بین پردازه‌ها با این روش سریع‌ترین حالت ممکن است. با این تفاسیر می‌توان Memory Mapped Fileها را به دو گروه تقسیم‌بندی کرد. اول گروهی است که در آن برنامه‌نویس یک فایل را به عنوانِ پشتیبان به سیستم‌عامل معرفی می‌کند و دومی گروهی که در آن فایلی به صورت مستقیم وجود ندارد و همان طور که بیان شد سیستم‌عامل از pagefile به‌صورت اتوماتیک استفاده می‌کند. در ادامه مثال‌هایی برای استفاده از Memory Mapped File آورده شده است:

مثال اول: خواندن و نوشتنِ فایل‌ها

در مثال زیر یک میلیون رکوردِ دو‌بایتی به دو روشِ مختلف از یک فایل خوانده و سپس نوشته می‌شود تا هم چگونگیِ کارکردِ Memory Mapped File مشخص شود و هم منظور از "سریع‌ترین روش برای نوشتن یا خواندن از فایل‌ها".

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 58 59 60 61 62 63 64 65 66 67 68 69class Program { const string FILE = "data.txt"; static void Main(string[] args) { CreateFileIfNotExist(); var watch = Stopwatch.StartNew(); UpdateRecordsUsingMemoryMappedFile(); watch.Stop(); Console.WriteLine($"Memory Mapped File: {watch.Elapsed}"); watch.Restart(); UpdateRecordsUsingFileStream(); watch.Stop(); Console.WriteLine($"File Stream: {watch.Elapsed}"); } // Create a dummy file of 2MB if needed static void CreateFileIfNotExist() { if (File.Exists(FILE)) { return; } var recSize = Marshal.SizeOf(typeof(Record)); var fs = new FileStream(FILE, FileMode.CreateNew); fs.Seek(1000000 * recSize - 1, SeekOrigin.Begin); fs.WriteByte(0); fs.Close(); } static void UpdateRecordsUsingMemoryMappedFile() { long length = new FileInfo(FILE).Length; using (var mmf = MemoryMappedFile.CreateFromFile(FILE, FileMode.Open)) { using (var accessor = mmf.CreateViewAccessor()) { int recsz = Marshal.SizeOf(typeof(Record)); Record rec; for (long i = 0; i < length; i += recsz) { accessor.Read(i, out rec); rec.Update(); accessor.Write(i, ref rec); } } } } static void UpdateRecordsUsingFileStream() { long length = new FileInfo(FILE).Length; var fs = new FileStream(FILE, FileMode.Open); while (fs.Position < length) { var buffer = new byte[2]; fs.Read(buffer, 0, 2); Record rec = new Record(buffer); rec.Update(); fs.Seek(-2, SeekOrigin.Current); fs.Write(rec.ToByte(), 0, 2); } } } public struct Record { public byte value1; public byte value2; public Record(byte[] buffer) { value1 = buffer[0]; value2 = buffer[1]; } public void Update() { value1++; value2++; } public byte[] ToByte() => new[] { value1, value2 }; }

اجرای کد بالا روی ماشینِ من خروجی زیر را تولید می‌کند. تفاوتِ سرعتِ دو روش کاملا آشکار است:

Memory Mapped File: 00:00:00.1211754
File Stream: 00:00:20.1794723

Memory Mapped Fileها از طریق objectهای view خوانده یا نوشته می‌شوند و مزیتِ وجودِ آن‌ها این است که می‌توان تنها قسمتی از فایل را به جای کلِ آن در view قرار داد. در کدِ بالا می‌توان به جای استفاده از ()MemoryMappedFile.CreateFromFile متدِ ()MemoryMappedFile.OpenExisting را به کار گرفت و از فایلی که در یک پردازه‌ی دیگر باز شده است استفاده کرد. این روش مبنای تعامل بین پردازه‌هاست.

مثال دوم: تعامل بین دو پردازه

در مثال زیر دو پردازه از طریقِ Memory Mapped File تعامل می‌کنند. توجه کنید پیاده‌سازیِ یک روشِ همگام‌سازی بینِ دو پردازه ضروری است چراکه پردازه‌ی دوم هیچ آگاهی نسبت به نوشته‌شدنِ اطلاعات در map ندارد. مثالِ زیر EventWaitHandle را برای این منظور به کار گرفته.

پردازه ی اول:

1 2 3 4 5 6 7 8 9 10 11 12 13 14static void Main() { var wait = new EventWaitHandle(false, EventResetMode.AutoReset, "mywait"); using (var mmf = MemoryMappedFile.CreateNew("mymap", 100)) { using (var stream = mmf.CreateViewStream()) { var writer = new BinaryWriter(stream); while (true) { Console.WriteLine("Enter a message:"); var msg = Console.ReadLine(); writer.Write(msg); wait.Set(); } } } }

پردازه‌ی دوم:

1 2 3 4 5 6 7 8 9 10 11 12static void Main() { var wait = EventWaitHandle.OpenExisting("mywait"); using (var mmf = MemoryMappedFile.OpenExisting("mymap")) { using (var stream = mmf.CreateViewStream()) { var reader = new BinaryReader(stream); while (true) { wait.WaitOne(); Console.WriteLine(reader.ReadString()); } } } }