Глава 3. Реализацията на ext3 за Hurd

Съдържание

3.1. Решаване на ограничението от 2G за файлова система
3.2. libe3pager
3.3. libscache
3.3.1. Функции на библиотеката
3.3.1.1. Жизнен цикъл
3.3.1.2. Работа с буфери
3.3.1.3. Буфери-сенки
3.3.1.4. Групи от буфери
3.3.2. Вътрешности
3.3.2.1. struct scache
3.3.2.2. struct buffer
3.3.2.3. struct bufset
3.3.2.4. Заключване и отключване
3.4. libjstore
3.4.1. Функции на библиотеката
3.4.1.1. Жизнен цикъл
3.4.1.2. Атомарни промени
3.4.1.3. Взимане и връщане на буфери
3.4.2. Вътрешности
3.4.2.1. struct jstore
3.4.2.2. struct transaction
3.4.2.3. struct update
3.5. libe3diskfs
3.5.1. Добавени към библиотеката функции
3.6. ext3fs
3.6.1. Промени
3.6.2. Проблеми
3.6.2.1. Файл, който се реферира единствено от процес
3.6.2.2. Операции, обхващащи много блокове с метаданни

В ядрото Linux достъпът до блоковите устройства почти винаги се извършва през block layer. Основните операции в него са взимане на блок и даване на блок, като явно се указва дали блокът е променен при даването. Същият подход се използва и в решаването на ограничението от 2G за store на файлова система.

Когато има block layer, винаги предварително се изпраща заявка за използване на даден блок. Връща се указател и след това блокът се освобождава. След освобождаването кодът не може да разчита, че указателят ще е валиден. Моделът е същият, както при използването на стандартните функции malloc и free.

Първата стъпка в правенето на дипломната работа беше премахване на гореспоменатото ограничение от 2G за големината на store. За решаване на този проблем се използва като отправна точка реализирания в почти всички операционни системи кеш на буферите (buffer cache), но по различен начин.

Имаме кеш от буфери, в които слагаме съдържанието на блокове от диска. Сървърът на файловата система иска все повече и повече блокове и в един момент целият кеш се запълва. Въпросът е съдържанието на кой буфер да изхвърлим, за да сложим новия блок.

Преди всички ние трябва да сме сигурни, че няма да изхвърлим буфер, който се използва в момента. За целта всеки буфер има брояч на потребителите (reference count). Един буфер никога не сменя асоциацията си, ако има потребител.

Целият код на ext2fs обаче въобще не води такова „счетоводство“ за това кой използва каква част от диска. Затова всичко беше променено да използва тази нова дисциплина за работа с блок от диска.

От останалите буфери трябва да изберем някой, който вече да има ново съдържание. В помощ на това идва микроядрото, което има глобален поглед върху това кои страници от адресното пространство се употребяват повече от други. Най-малко употребяваните просто се изваждат, когато е нужна памет за други страници. Това изваждане се прихваща и така се знае във всеки един момент от време кои страници са извадени и кои са заредени в адресното пространство. За нов блок винаги се избира буфер, чиято страница е извадена и който няма потребители.

В ext3fs цялата тази логика е изнесена в libscache.

Към тази библиотека е добавена възможността да съобщава, когато страница от паметта се извади (evict). За целта се използват така наречените ценни страници (precious pages). Когато микроядрото Mach иска някоя страница да бъде записана, то вика потребителския RPC memory_object_data_return. Аргументът kcopy показва дали ядрото все още държи копие („kernel copy“) на страницата. (Това копие е всъщност страницата в адресното пространство на задачата.) Когато страницата е отбелязана като ценна, тази функция се вика винаги, когато страницата повече няма копие. По този начин ние знаем кога страницата е извадена.

Стандартната библиотека libpager няма възможности за управление на ценни страници, така че те са добавени.

Това е нова библиотека, която реализира кеша на буферите. Работи върху склад (store) и дава при поискване буфери, съдържащи блокове от склада. Важно е всяко поискване на буфер да бъде съпроводено и с освобождаването му, понеже се държи сметка колко потребители има даден буфер във всеки един момент от време.

В реализацията на библиотеката броят на буферите е фиксиран брой. Когато няма свободен буфер, търси се такъв, чиято страница е извадена и който няма потребители. Съдържанието на такъв буфер се заменя с това на новия блок.

Освен тези най-прости действия – даване и приемане обратно на буфери – към библиотеката са добавени и две способности от по-високо ниво. Едната е буферите-сенки (shadow buffers), които са копия на истинските буфери (real buffers). Промените в буферите-сенки не се отразяват върху блоковете на диска, откъдето са копирани. Също така един и същ буфер може да има много буфери-сенки. Поискването на даден блок чрез scache_get винаги връща истински буфер. Затова търсенето на буфер-сянка трябва да става винаги в някоя група от буфери (buffer set).

Групата от буфери е втората необичайна способност на libscache. Това позволява да се правят действия върху точно определен списък от буфери. Тези действия са връщане (записване) и игнориране на промените на страниците на буферите, както и търсене на блок в рамките на група. Не се позволява да има два буфера за един и същ блок вътре в група, но няма проблем някои буфери да са сенки. Принадлежността на буфер към група не увеличава брояча на потребителите на буфера и затова потенциално във всеки един момент някой буфер може да „изчезне“ от всички групи, в които се намира, ако съдържанието на буфера се замени с друг блок.

struct scache_buffer *scache_get( struct scache *  scache,
  scache_block_t   block);

Тази функция връща буфер, който съдържа истинския блок. Броячът на потребителите на буфера се увеличава. Единствената легална употреба на получения буфер е използване на полето-указател data, което сочи съдържанието на блока.

void scache_put( struct scache *  scache,
  struct scache_buffer *  buffer);

С извикването на тази функция се освобождава буфера, като се намалява брояча на потребителите. След това извикване всяка употреба на буфера е недопустима, тъй като буферът може вече да съдържа друг блок.

void scache_ref( struct scache *  scache,
  struct scache_buffer *  buffer);

Понякога се налага буферът да бъде предаден на функция, която извиква го освобождава чрез scache_put. Ако е нужно същият буфер да се използва и след функцията, може да се увеличи брояча на потребителите на буфера и така да се продължи да се използва буфера след извикване на scache_put.

Има само една функция, която пряко работи със сенки:

struct scache_buffer *scache_copy( struct scache *  scache,
  struct scache_buffer *  buffer,
  struct scache *  target_scache,
  scache_block_t   target_block,
  int   want_shadow);

Функцията прави копие на буфера buffer. Копието може да бъде в друг кеш target_scache, или в същия, което може да бъде отбелязано чрез предаване на NULL. Копирането може да запише съдържанието на един блок в друг блок с номер target_block, или в същия номер на блок, което може да бъде отбелязано чрез предаване на SCACHE_NO_BLOCK. И накрая, целевият буфер може да бъде нов буфер-сянка, което се задава чрез ненулева стойност на want_shadow. Ако се иска сянка, тя винаги е нова сянка, а ако се иска истински буфер, той винаги е единственият буфер за този блок в целевия кеш. На получения буфер се увеличава брояча на потребителите, но както вече беше казано, за да се освободи буфер-сянка, не само броячът на потребителите трябва да е нула, но и буфера-сянка да не е в нито една група.

struct scache_bufset *scache_bufset_new( struct scache *  scache);

Създаване на нова група от буфери.

void scache_bufset_delete( struct scache_bufset *  bufset);

Унищожава групата от буфери bufset. Преди това всички буфери от групата се изключват от групата

error_t scache_bufset_add( struct scache_bufset *  bufset,
  struct scache_buffer *  buffer);

Добавяне на буфера buffer към групата от буфери bufset. Буферът трябва да е от същият кеш, какъвто е и на групата. Също така в групата не трябва вече да има друг буфер за същия блок, т.е. един и същ буфер може да се добавя много пъти в групата, но ако се добави друг буфер за същия блок, например буфер-сянка, ще бъде върната грешка EEXIST.

error_t scache_bufset_remove( struct scache_bufset *  bufset,
  struct scache_buffer *  buffer);

Буферът buffer се изключва от групата буфери bufset.

struct scache_buffer *scache_bufset_find( struct scache_bufset *  bufset,
  struct scache_buffer *  buffer);

Проверява дали буферът buffer е в групата bufset. Ако е там, връща се buffer, иначе се връща NULL. Буферът buffer трябва да е отчетен като потребител, но това не се консумира.

struct scache_buffer *scache_bufset_get( struct scache_bufset *  bufset,
  scache_block_t   block);

Търси дали в групата bufset има буфер за блок block, дори и да е буфер-сянка. Ако има, увеличава му се броячът за потребителите и буферът се връща като резултат. Ако няма, връща се NULL.

error_t scache_bufset_put( struct scache_bufset *  bufset,
  struct scache_buffer *  buffer);

Връща взетия от групата bufset чрез scache_bufset_get буфер buffer.

error_t scache_bufset_iterate( struct scache_bufset *  bufset,
  error_t  (*func) (struct scache_buffer *, void *);
  void *  arg);

Функцията func се извиква за всеки буфер на групата bufset. Аргументът arg се предава като втори аргумент на func.

error_t scache_bufset_return( struct scache_bufset *  bufset,
  int   wait);

Всички буфери с променено съдържание от групата bufset се записват в склада. Ако wait не е нула, при връщането от функцията всички буфер са синхронизирани. Буферите-сенки не се влияят от тази функция.

error_t scache_bufset_flush( struct scache_bufset *  bufset,
  int   wait);

Игнорира промените във всички буфери от групата bufset. Следващият достъп до който и да е буфер ще прочете оригиналното непроменено съдържание на блока. Ако wait не е нула, при връщането от функцията всички промени в буферите са игнорирани, иначе ефектът може да дойде малко по-късно. Буферите-сенки не се влияят от тази функция.

Структурата scache представя един кеш на буфери. Броят на буферите се определя при създаването на кеша и не може да се променя по-късно. Винаги се използва препоръчвания размер, който се дава на scache_open. Този размер се разделя на исканата големина на блок и така се получава броя на буферите.

Полето buffer сочи към масив от всички налични буфери. Съответните им данни са в соченото от image пространство. Така, имайки номера на буфера, лесно може да се изчисли къде са неговите данни, както и имайки указател към данни, да се изчисли на кой буфер съответстват. Обаче буферите-сенки не пазят данните си в image.

Главната причина за поставянето на всички данни в непрекъсната област от адресното пространство е, за да може да се използва libe3pager. Така libscache напълно определя какво съдържание ще има всяка страница от тази област. За целта са определени потребителски функции scache_pager_* за всички нужни потребителски функции pager_*, които се използват от libe3pager. Това позволява потребителят да има собствени страницатори. Потребителят трябва да определи функциите pager_*, които винаги приемат като един от параметрите си pager_info, който е указател към тип struct pager_info. Ако първата 32-битова дума, сочена от указателя, е SCACHE_PAGER_INFO_MAGIC, то потребителят трябва да остави libscache да обработи извикването чрез съответната функция scache_pager_*.

Изображението от номер на блок към истински буфер се пази в полето hash. Буферите-сенки не се отразяват по никакъв начин на това изображение, а могат да бъдат достигнати само чрез групите от буфери.

При искане на блок, който го няма в кеша, scache_get трябва да пренастрои някой буфер така, че да съдържа искания блок. Тази операция е същността на библиотеката. Първо, трябва да се намери подходящ буфер. Преди всичко той не трябва да се използва в момента, т.е. броячът на потребителите му трябва да е нула. След това трябва да е такъв, че да е не е използван наскоро. Това се постига чрез търсене на буфер, чието съдържание е изтласкано от адресното пространство. Страницатор на libscache чрез scache_pager_notify_pageout се грижи винаги да поддържа информация кой буфер е изтласкан и кой – не. Търсенето на такъв буфер започва от буфер hint и продължава последователно. Ако се стигне последния буфер, преминава се към първия. След като се намери буфер, hint става следващия буфер. По този начин винаги се търси първо измежду най-старите буфери, защото за да се стигне до току-що избрания буфер, hint трябва да обиколи всички останали буфери.

Ако все пак не се намери буфер, отговарящ на условията, прави се опит да се изтласкат всички буфери, който имат нулев брояч на потребителите. После се пробва пак.

Полето block е номерът на блока, съдържащ се в буфера, а data сочи към данните на буфера. Тези две полета е позволено да се използват от потребителите, но само за четене. При истински буфер data може да се изчисли, но когато буферът е сянка, това е допълнително заделена чрез mmap памет. Това изисква и допълнителни действия, когато буферът се свързва с друг блок от диска. За улеснение буферите-сенки се освобождават веднага, след който никой вече не ги използва. Условието за това е броячът на потребителите да е нула и те да не са в никоя група от буфери. В момента, когато това стане изпълнено, например чрез scache_put или scache_bufset_remove, буферът-сянка става истински буфер, който обаче не сочи към блок.

Полето bufsets е изображение и служи като множество на групите, в които буферът е включен. Ключът и стойността в това изображение съвпадат и те са групата.

Полета flags показва дали буферът е в паметта (SCB_INCORE) и дали е сянка (SCB_SHADOW). Флагът SCB_UNREAD се използва при свързване на буфера с друг блок от диска. Проблемът е, че трябва буферът за малко да се отключи. През това време може някоя друга нишка също да търси буфер и да използва този буфер, който сме избрали. За да се избегнат проблемите, буферът се отбелязва като недокосван, отключва се и след като пак се заключи, проверява се дали някой не го е докосвал. Ако е докосван, този буфер се оставя и търсенето на подходящ буфер за блока продължава.

Последното поле е ref_count и то съдържа броя на потребителите, които използват в момента този буфер.

Тази библиотека е еквивалентът на JBD (Journalled Block Device), само че за складове (store), а не за блокови устройства. Изцяло се стъпва върху libscache, като буферите-сенки и групите от буфери са съществена част от реализацията. Предполага се, че потребителят на библиотеката изцяло ще използва libjstore за достъп до склада, използвайки изключително двете функции jstore_block_get и jstore_block_put. Буферите винаги се взимат и оставят в рамките на някоя атомарна промяна (update), чието начало и край се бележат с jstore_update_begin и jstore_update_end. Нужно е предварително да се знае колко най-много блока ще обхваща атомарната промяна, за да не се получават препълвания на журнала поради „дълги“ транзакции. При нужда може да се направи опит за увеличаване на първоначално заявения брой блокове чрез jstore_update_more_credits.

#define JSTORE_OPEN_RECOVER   (1 << 0)
#define JSTORE_OPEN_READONLY  (1 << 1)
struct jstore *jstore_open( struct scache *  journal_scache,
  struct scache *  data_scache,
  int   flags);

Отваряне на журнала, съхраняван в journal_scache. Данните, които се журнализират, са в data_scache. Аргументът flags настройва отварянето. Флагът JSTORE_OPEN_RECOVER разрешава автоматичното възстановяване на съгласуваността на журнализираните данни, ако в журнала има информация за това. Флагът JSTORE_OPEN_READONLY отваря журнала в режим само за четене. Това забранява връщането на буфери чрез jstore_block_put на променени блокове.

error_t jstore_change_readonly( struct jstore *  jstore,
  int   readonly,
  struct jstore_update *  update);

Промяна на режима на журнала – дали журналът ще е само за четене, или и за четене, и за промяна. При режим само за четене е забранено връщането на променени буфери чрез jstore_block_put. При този режим журналът е напълно „чист“ и не е строго необходимо да се изпълни jstore_close, за да изглежда журналът празен.

error_t jstore_close( struct jstore *  jstore);

Затваряне на журнала jstore.

struct jstore_update *jstore_update_begin( struct jstore *  jstore,
  scache_block_t   credits);

Започване на атомарна промяна в журнала jstore. Обявява се колко блока credits най-много ще засегне промяната. Това е нужно, за да се гарантира, че извършителят на промените няма да бъде изненадан по средата с грешка, че няма повече налични блокове в транзакцията.

error_t jstore_update_more_credits( struct jstore_update *  update,
  scache_block_t   blocks,
  int   allow_restart);

Опит за увеличаване на кредита, даден за атомарната промяна update, с още blocks блока. Ако не може да бъдат дадени толкова блока и allow_restart е нула, връща се незабавно грешка. Ако е разрешено рестартирането на атомарната промяна и не могат да бъдат заделени толкова блокове, атомарната промяна приключва, изчаква се създаването на нова транзакция и от нея се взимат нужния брой блокове. В този случай извършителят на атомарната промяна трябва да е довел промените в един завършен вид, за да може в случай на рестартиране на промяната всичко винаги да остане съгласувано.

error_t jstore_update_end( struct jstore_update *  update);

Завършване на атомарната промяна update.

#define JSTORE_GET_CREATE     (1 << 0)
#define JSTORE_GET_COMMITTED  (1 << 1)
#define JSTORE_GET_DATA       (1 << 1)
struct scache_buffer *jstore_block_get( struct jstore_update *  update,
  scache_block_t   block,
  int   flags);

Връща буфер за блока block, който да се чете и/или променя като част от атомарната промяна update. По подразбиране се приема, че се искат метаданни, освен ако не е зададен флагът JSTORE_GET_DATA. Ако е зададен флагът JSTORE_GET_CREATE, предишното съдържание на блока няма значение и той трябва да се получи нулиран. Това е малка оптимизация. Ако е зададен флагът JSTORE_GET_COMMITTED, иска се съдържанието на метаданни както вече са записани в завършена транзакция. Този флаг не трябва да се използва с друг флаг.

#define JSTORE_PUT_NOTMODIFIED  (1 << 0)
#define JSTORE_PUT_FORGET       (1 << 1)
#define JSTORE_PUT_REVOKE       (1 << 2)
#define JSTORE_PUT_CLEAN        (1 << 3)
void jstore_block_put( struct jstore_update *  update,
  struct scache_buffer *  buffer,
  int   flags);

Връщане на буфера buffer, взет за атомарната промяна update. Ако не е променян, това трябва да се отбележи със задаването на флага JSTORE_PUT_CLEAN. Ако промените в буфера трябва да се игнорират, задава се флагът JSTORE_PUT_FORGET. Ако буферът е взет като метаданни и с тази атомарна промяна вече няма да се използва като метаданни, т.е. вече може да се използва за данни, трябва да се зададе флагът JSTORE_PUT_REVOKE.

Полетата journal_scache и data_scache сочат кешовете на журнала и на файловата система, съответно. Полето is_readonly показва дали режимът на журнала е само за четене.

Главният блок на журнала често се променя. Затова има полета за него. Буферът е superblock_buffer, като съдържанието му се сочи от superblock. За да може лесно да се записва, superblock_bufset е група, включваща само буфера на главния блок. Така записването на блока се състои в извикването на scache_bufset_return.

Транзакциите са от 3 типа: текуща, записваща се и изчакваща. Текущата е running_transaction, записващата е committing_transaction, а групата от изчакващите транзакции се слага в изображението checkpointed. В него ключът и стойността са самата транзакция.

Полето checkpointed_holes е вектор на освободените места в журнала, които са след tail_block. Полетата checkpointed_holes_count и checkpointed_holes_size са съответно текущата големина на вектора и заделената му големина.

Полето next_sequence е следващия номер, който да бъде използван за транзакция. Най-старият все още използван номер е tail_sequence.

Първият и последният блок от журнала са first_block и last_block-1, съответно. Следващият блок за заделяне е head_block, а tail_block е последният заделен блок. Така че заетите блокове в журнала са от tail_block до head_block-1, като при достигане на края на журнала (last_block), се продължава с началото (first_block). Незаетите блокове са free_nblocks на брой.

Кодът на фаталните грешки (като лош блок например) се пази в err.

При поискване на блок от страна на потребителя, важно е да знаем в коя транзакция се намира буферът, съдържащ този блок. За целта all_blocks е изображение от номер на блок към група от буфери, където се намира буфера на този блок. Имайки групата, сравнително лесно е да определим и самия буфер, както и транзакцията, в която е той. Обаче е възможно да не намерим блока в дадената група. Причината е, че той може автоматично да е изключен от групата, ако е в изчакваща транзакция. За ранно откриване на такива проблеми се използва групата all_blocks_bufset, която обхваща всички буфери, използвани от журнала.

Полето condition се използва за „разгласяване“ (broadcast), че има промяна в running_transaction или committing_transaction.

Условието“ (condition) condition служи за синхронизиране. Чакането за промяна в ref_count се заключава в чакане на това условие, което се „разгласява“ (broadcast) след извършване на промяна в ref_count. Това се използва, когато заявка за commit на текущата транзакция чака всички атомарни промени в текущата транзакция да завършат.

В полето jstore е журналът, към който принадлежи транзакцията.

Номерът на транзакцията се съдържа в полето sequence.

Полето revoked е хеш-таблица, представяща множество от revoked блокове в тази транзакция. Ключът и стойността са номера на блокове.

Транзакциите обхващат няколко групи от буфери. Тези групи са:

data_bufset

Истински буфери с данни, които не подлежат на журнализиране.

metadata_bufset

Буфери-сенки с метаданни, които ще се записват предварително в журнала.

committed_bufset

Буфери-сенки с копия на метаданни преди промените в текущата транзакция.

log_bufset

Истински буфери за всичко, което отива в журнала. Например тук са копията на метаданните, които се записват в журнала.

shadow_metadata_bufset

Спомагателна група, участваща в прехода на метаданните от буфери-сенки в истински буфери.

Броят на позволените блокове, които могат да се слагат в журнала в тази транзакция, се държи в полето credits.

Броят на текущите потребителите на транзакцията се държи в ref_count. Например атомарните промени се броят за потребител, докато те не завършат.

Checkpoint транзакциите трябва да съдържат информация за това кой отрязък от журнала са ангажирали, за да се знае кои части от журнала са свободни за повторна употреба. Началото и края се пазят в полетата begin и end.

Основната промяна спрямо стандартния libdiskfs е добавянето на аргумент rpc_context към почти всички функции на библиотеката. Такъв обект се създава в началото на всеки RPC към сървъра на файловата система и се разрушава в края на RPC-то. Създаването и разрушаването се извършва от потребителските функции diskfs_rpc_context_new и diskfs_rpc_context_delete, които са дефинирани в ext3fs.

ext3fs е променено копие на ext2fs на Hurd, но използващо libjstore за достъп до диска.

Само по себе си, използването на библиотеката libjstore не е достатъчно за пълно възстановяване на файловата система при непредупредено спиране на транслатора, например при спиране на тока.

Нека файл е само в една директория, т.е. няма твърди връзки от други директории. Ако някой процес отвори този файл и той се изтрие преди затварянето му, тогава продължава да съществува на диска, въпреки че не се реферира от никоя директория. Това е специфична семантика на изтриването на файл във всички UNIX варианти. При внезапно спиране на транслатора и последващо преиграване на журнала ние ще получим файл, който заема блокове с данни и не може да бъде достигнат по никакъв начин, понеже го няма в никоя директория. Файловата система не е съгласувана.

Този проблем не може да бъде избягнат единствено със средствата на журнала. За целта в ext3 се поддържа едносвързан списък на файлови описатели, които не се реферират от никоя директория и трябва да бъдат изтрити след преиграване на журнала, когато транслаторът е бил прекъснат внезапно. Тези файлови описатели се наричат осиротели (orphaned) и функциите за тяхната обработка се намират в ext3fs/orphan.c.

При изтриване на много голям файл трябва да се правят промени в много косвени блокове и е възможно атомарната промяна да не може да осигури нужните кредити, за да се извърши цялата операция в една транзакция. В този случай се налага операцията да се извърши върху няколко транзакции, което значи, че при внезапно прекъсване на транслатора файлът може да остане ненапълно изтрит.

За предотвратяване на този случай се използва списъка на осиротелите файлови описатели. Семантиката на осиротелите файлови описатели се променя по следния начин:

  • Ако никоя директория не реферира файловия описател, при възстановяване трябва файлът да бъде изтрит.

  • Ако някоя директория реферира файловия описател, при възстановяване трябва файлът да бъде отрязан до зададената във файловия описател дължина.