В предыдущей статье мы рассмотрели операционные системы Inferno и Plan 9 с теоретической точки зрения. В третье статье мы познакомимся с языком Limbo.

Евгений Зобнин, разработчик, Независимый разработчик

Евгений Зобнин. Работает с UNIX-системами с 1998 года. Автор более сотни статей на тему администрирования и работы с UNIX-подобными системами. Занимается web-разработкой (Python, Django), ведет блог об операционной системе Inferno.



08.11.2011

Перед тем как перейти к разработке распределенных приложений для ОС Plan 9 и Inferno мы должны ознакомиться с языком программирования Limbo, используемым в Inferno для создания прикладного ПО. Если вы уже знаете язык Си, Limbo покажется вам знакомым и вы легко его освоите, всем остальным рекомендую прочитать статьи или книги, посвященные введению в Си.

Limbo - высокоуровневый модульный язык со встроенными средствами многопоточного программирования, созданный специально для использования в среде Inferno. Мы уже рассмотрели ключевые возможности и достоинства этого языка в первой части цикла, поэтому не будем останавливаться на его описании, а сразу перейдем к практической части.

Модули

Одним из ключевых отличий Limbo от многих других языков является концепция модулей. В Inferno (а следовательно и в Limbo) нет разделения исполняемого кода на библиотеки и программы. Вместо этого используется система динамически загружаемых модулей, каждый из которых имеет набор функций, через обращение к которым происходит запуск содержащегося в модуле кода. Таким образом любой модуль может загрузить в память другой модуль и вызвать одну или несколько его функций. Чтобы узнать какие функции загруженного в память модуля доступны для вызова, вызывающий модуль обращается к его интерфейсу.

Исходный код модуля обычно помещается в так называемый файл реализации, имеющий расширение .b. Структура такого файла выглядит следующим образом:

* Первая строка файла всегда начинается с ключевого слова implement, следом за которым идет имя модуля:

implement NewModule;

* Далее могут следовать комментарии, начинающиеся со знака решетки (#), а также описание интерфейса модуля:

NewModule: module { 
    имя_функции: fn(аргумент1: тип, аргумент2: тип) : тип_возвращаемого_значения; 
    ... 
};

* За ним должны следовать описания интерфейсов всех модулей, которые будут загружены и использованы текущим:

FirstModule: module { 
    ... 
}; 
 
SecondModule: module { 
    ... 
}; 
 
...

* Далее могут идти объявления глобальных переменных и констант (о них позже).

* В конце файла обычно размещаются определения функций (тех, которые были перечислены в объявлении интерфейса модуля и других, предназначенных для внутреннего использования):

имя_функции (аргументы) : тип_возвращаемого_значения { 
    операторы 
}

О разработке приложений

В Inferno есть все для эффективной разработки приложений. Вы можете использовать среду разработки acme или воспользоваться более простым и привычным редактором wm/edit. В качестве источника информации используйте команды man и wm/man, начните с чтения документа intro второй секции. Система поставляется с мощным отладчиком wm/deb (для его использования запускайте компилятор limbo с флагом -g) и утилитами для выполнения профилирования wm/prof, wm/cprof и wm/mprof.

Порядок следования этих элементов файла реализации может быть и другим, требованием является лишь наличие строки implement в самом начале файла. Кроме функций в описании интерфейса также могут присутствовать переменные и константы, которые также будут доступны вызывающим модулям.

Чтобы не дублировать содержимое нескольких файлов реализации, Limbo допускает вынесение любых его частей в отдельный файл, содержимое которого можно включить в текущий файл с помощью оператора include. Обычно эта возможность используется для вынесения объявления интерфейса модуля в файл, называемый файлом интерфейса (обычно он имеет расширение .m). Они исполняют функцию, аналогичную заголовочным файлам языка Си (.h).

Имя модуля, указанное в файле реализации, совсем не обязательно должно совпадать с именем, указанным в файле интерфейса. При загрузке модуля и обращении к его функциям исполняющая среда Inferno сравнивает не имена модуля и его интерфейса, а сигнатуры предоставляемых им функций.

Вокруг этой особенности построена система "запуска" модулей, исполняющих роль обычных программ. В Inferno, чтобы превратить модуль в программу в классическом понимании этого слова, достаточно поместить объявление функции init, имеющей заранее определенную сигнатуру, в описание интерфейса этого модуля и затем определить ее в его реализации. Именно с нее начнется исполнение (аналог функции main в языке Си).


Старый-добрый Hello World

Классический пример программы Hello World, написанной на языке Limbo, состоит из 15 строк:

1 implement HelloWorld; 
2 
3 include "sys.m"; 
4 include "draw.m"; 
5 
6 sys: Sys; 
7 
8 HelloWorld: module { 
9     init: fn(nil: ref Draw->Context, argv: list of string); 
10 }; 
11 
12 init(nil: ref Draw->Context, argv: list of string) { 
13     sys = load Sys Sys->PATH; 
14     sys->print("Hello, world!"); 
15 }

В первой строке мы даем Limbo-компилятору понять что этот файл - реализация модуля под названием HelloWorld.

Строки 3 и 4 включают в файл описания интерфейса системных модулей Sys и Draw из файлов /module/sys.m (системные вызовы ядра и подсобные функции) и /module/draw.m (API графической подсистемы). Оператор include выполняет функцию, аналогичную директиве препроцессора #include языка Си, однако позволяет указывать имена файлов только в кавычках (поиск файлов интерфейсов происходит последовательно в двух каталогах: текущем и /module).

Шестая строка объявляет переменную sys типа Sys. В дальнейшем она будет использована для доступа к функциями модуля Sys (строка 14).

Строки с восьмой по десятую - это описание интерфейса модуля HelloWorld, которое содержит объявление init в качестве функции, принадлежащей этому модулю. Командный интерпретатор будет использовать интерфейс модуля Command, имеющий приведенное ниже описание, для обращения к функции init нашего модуля:

Command: module { 
    init: fn(nil: ref Draw->Context, argv: list of string); 
};

Как видите, за исключением имени, он полностью идентичен интерфейсу модуля HelloWorld.

Функция init принимает два аргумента, имеющие типы ref Draw->Context (тип Context, определенный в модуле Draw) и list of string (список строк). Это графический контекст (используемый для доступа к графическим функциям) и список аргументов командной строки.

Строки с 12-ой по 15-ю - определение функции init. Она содержит всего две строки: первая загружает модуль Sys в память и присваивает его адрес переменной sys, вторая - вызывает функцию print этого модуля (функция print модуля Sys - это эквивалент функции printf из библиотеки libc).

Инструкция load принимает два аргумента: имя модуля (а точнее имя интерфейса модуля) и его адрес на диске. Программист вправе указывать путь к модулю самостоятельно (это полезно, если у вас есть собственная версия модуля, расположенная в другом каталоге), но по соглашению путь принято указывать в константе PATH, объявленной в описании интерфейса модуля.


Сборка модуля

Для компиляции модуля, сохраните его в каталог с установленной Inferno под именем helloworld.b, запустите Inferno (установка и способ запуска были описаны во второй статье цикла), откройте окно командного интерпретатора (Shell) и запустите на исполнение следующую команду:

; limbo helloworld.b

Если в файле нет ошибок, то в текущем каталоге появится исполняемый файл helloworld.dis. Для его запуска наберите команду:

; ./helloworld 
Hello, world!

Типы данных

В Limbo есть две разновидности типов данных: примитивные и ссылочные. К первым относятся числовые типы, перечисленные в нижеприведенной таблице, а также высокоуровневый тип string, предназначенный для представления строк.

типдиапазон значений
byteБеззнаковое 8-битное целое, длина - 1 байт
int32-битное целое со знаком
big64-битное целое со знаком
real64-битное число с плавающей точкой двойной точности стандарта IEEE

С типами byte, int, big и real можно производить любые операции, допустимые в языке Си, кроме "запоздалых" операций, таких как "=+" (при этом "+=" вполне законна). Оператор присваивания в Limbo имеет две формы. Классическая форма (=) используется тогда, кагда перменная уже была объявлена ранее. Например:

a : int; # объявление переменной типа int 
a = 255; # присвоение переменной значения 255

Вторая форма может быть использована для одновременного объявления и инициализации переменной (при этом ее тип будет выбран автоматически):

b := 255; # объявление переменной b типа int и присвоение ей значения 255

Тип string обеспечивает хранение строк и позволяет производить только ограниченное число специфических операций над собой:

* Присвоение:

a : string; 
a = "это строка"; 
b := "и это строка";

* Сложение:

c := a + " " + b; # значение переменной c: "это строка и это строка"

* Вычисление длины:

d := len string; # значением d будет число 10

* Обнуление:

b = nil;

* Срез:

e := a[0:3]; # значением e будет строка "это" 
f := a[4:];  # значением f будет строка "строка"

В памяти строки хранятся в кодировке UTF-16, в то время как на диске - в UTF-8. Преобразование происходит "на лету", поэтому вы можете использовать любые Unicode-символы в своих программах, не беспокоясь о возможных проблемах.

Константы в Limbo объявляются с помощью ключевого слова con:

buf_size : con 2048; 
name : con "My program;

При этом константы можно перечислять с помощью ключевого слова iota. Например:

one, two, three : con iota; # значения констант: 0, 1, 2 
five, six, seven : con iota+5; # значения констант: 5, 6, 7 
zero, Five, Ten : con iota*5; # значения констант: 0, 5, 10

Так называемые "ссылочные" типы используются в Limbo для хранения данных, доступ к которым происходит по ссылке, а не по значению. К таким типам относятся массивы, списки, кортежи и абстрактные типы данных (ADT). В отличие от примитивных типов (кроме string), которые сразу после объявления имеют неопределенное значение, ссылочные типы содержат значение nil, идентичное NULL в языке Си. В отличие от указателей Си адресом ссылочного типа нельзя манипулировать (кроме присвоения значения nil).

Массивы языка Limbo ничем не отличаются от массивов в других языках программирования. Это все та же последовательность однотипных пронумерованных данных. Объявление массива в Limbo очень похоже на объявление массива в языке Pascal:

a : array of int;

Чтобы заранее зарезервировать место для элементов массива используйте следующую конструкцию:

a : array[255] of int;

Для обращение к элементу - оператор взятия элемента:

b := a[2];

Для предварительной инициализации массива используйте следующую конструкцию:

c := array[] of { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

При этом элементы массива могут быть автоматически заполнены нужным вам значением:

d := array[10] of { * => byte 5 };

Массив можно преобразовать в строку и наоборот:

e := string c; 
c := array of byte e;

Также как и строки массивы можно срезать, вычислять их длину с помощью оператора len и обнулять присваивая значение nil.

Списком (list) в Limbo называется тип данных, предназначенный для хранения последовательности элементов, организованных в стек. У списка есть голова, которую составляет первый элемент списка, и хвост - все остальные элементы. Для создания списка следует использовать следующую конструкцию:

a : list of string; # объявление списка строк 
a = "первый" :: "второй" :: "третий"; # создание списка из трех элементов

В любой момент к существующему списку можно добавить новые элементы, причем как к началу, так и к концу:

a = "нулевой" :: a; 
a = a :: "четвертый";

С помощью операторов hd и tl у списка можно взять голову и хвост:

b := hd a; # значением b будет строка "нулевой" 
a := tl a; # теперь a содержит изначальный список без первого элемента (головы)

Другой "составной" тип данных Limbo называется кортежем (tuple) и позволяет группировать данные любых типов для их обработки в качестве одного объекта. Кортежи создаются с помощью заключения перечисленных через запятую элементов в скобки. Например:

a : (int, string); # a - кортеж из двух элементов типа int и string 
a = 5, "да"; 
 
b := "виски"; 
c := (8, b, 11); # c - кортеж из трех элементов типа int, string, int

В Inferno кортежи используются повсеместно в качестве удобного способа возвращения нескольких значений из функции. Многие функции системных модулей возвращают кортежи из двух элементов, первый из которых содержит код завершения, а второй - возвращаемое значение.

Абстрактный тип данных (ADT) языка Limbo очень похож на структуры языка Си. Однако в дополнение к обычным данным он может включать в себя и функции, подобно классам ООП-языков. Абстрактный тип данных не поддерживает наследование, полиморфизм и не обладает другими характерными чертами классов, но мы будем использовать термины словаря ООП для более простого и ясного изложения.

Итак, объявление ADT выглядит примерно так:

идентификатор: adt { 
    поле; 
    поле; 
    ... 
    метод; 
    метод; 
    ... 
};

Где поле - это какие-либо данные, а метод - функция, "привязанная" к ADT. Например:

Clock: adt { 
    name: string; 
    hour: int; 
    min: int; 
    sec: int; 
    get: fn(c: Clock): (int, int, int); 
};

Эта запись объявляет новый абстрактный тип данных Clock (по соглашению имена модулей и классов начинаются с заглавной буквы), который имеет три поля и один метод, который имеет объявление, но еще не имеет реализации. Обычно объявление ADT и реализацию его методов разделяют, помещая первое в файл объявления модуля (.m), а второе - в файл реализации (.b). Это не обязательное требование, а лишь способ сделать ADT внешней структурой, которую смогут использовать другие модули. В любом случае реализация методов ADT выглядит так же как реализация обычной функции с уточняющей записью, говорящей о том, к какому ADT он принадлежит:

Clock.get(c: Clock): (int, int, int) { 
    return (c.hour, c.min, c.sec); 
}

После объявления ADT становится полноправным ссылочным типом данных, таким же как массив или список, и вы можете объявлять переменные с этим типом:

clock: Clock;

Для доступа к полям и методам такой переменной следует использовать точку, точно также как при доступе к элементам экземпляра класса в языках С++ и Java:

clock.name = "Цифровые часы"; 
clock.hour = "13"; 
clock.min = "43";

Доступ к методам имеет свою специфику. Как видно из приведенного выше определения метода get, для того, чтобы он смог получить доступ к полям своего же ADT, он должен получить его экземпляр в качестве аргумента. Поэтому вызов метода выглядит несколько странно и избыточно:

(h, m, s) := clock.get(clock);

Чтобы избежать этого следует использовать ключевое слово self, с помощью которого экземпляр ADT можно передать как неявный аргумент.

Вернемся к нашему примеру и перепишем его с использованием self:

Clock: adt { 
    name: string; 
    hour: int; 
    min: int; 
    sec: int; 
    get: fn(c: self Clock): (int, int, int); 
}; 
 
Clock.get(c: self Clock): (int, int, int) { 
    return (c.hour, c.min, c.sec); 
}

Теперь метод можно вызывать без явной передачи экземпляра ADT:

(h, m, s) := clock.get();

Структуры управления

Структуры управления языка Limbo практически не отличаются от таковых в языке Си. Оператор выбора if имеет две формы:

if (выражение) оператор 
if (выражение) оператор1 else оператор2

Как и в языке Си выражение должно вернуть результат типа int, в зависимости от которого будет выполнен оператор1 (оператор) или оператор2. В дополнение к if предусмотрен также оператор множественного выбора, который имеет следующую форму:

case выражение { 
    квалификатор => оператор 
}

Оператор case похож на оператор switch из языка Си, но имеет отличия в синтаксисе и поведении. Рассмотрим простой пример:

case x { 
    1 => 
	y = a+b; 
    2 or 3 => 
	y = a/b; 
    4 .. 9 => 
	y = a*b; 
	z = a/b; 
    * => 
	sys->print("error: %d\n", x); 
}

Как видите квалификатор может быть константой, множеством значений или диапазоном. Все, что находится после квалификатора, представляет собой блок, который заканчивается с началом следующего квалификатора. В отличие от оператора switch, case не требует прерывать каждый блок оператором break, это происходит автоматически.

Выражение не обязано возвращать значение типа int, это может быть и string:

case figure { 
    "line" => draw.line(); 
    "square" => draw.square(); 
    "triangle" => draw.triangle(); 
    "ellipse" => draw.ellipse(); 
    * => log->error("Unknown figure"); 
}

Операторы повтора for, while и do...while полностью идентичны своим аналогам в языке Си, поэтому я не буду на них останавливаться, а только приведу форму записи:

for {выражение1; выражение2; выражение3} оператор; 
 
while (выражение) оператор; 
 
do оператор; 
while (выражение);

Так же как в языке Си операторы break и continue используются для принудительного прерывания циклов и в выражениях case и alt (о нем в следующем разделе). Однако, в отличие от Си они могут принимать адрес метки в качестве аргумента. Это можно использовать, например, для выхода из вложенных циклов.


Многопоточное программирование

Limbo отличается от большинства других языков наличием весьма мощных и простых в использовании средств для программирования многопоточных приложений, которые базируются на модели коммуникационных с использованием каналов.

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

На практике мы получаем следующий набор конструкций языка, обеспечивающий перечисленные функции:

  • Оператор spawn, порождающий новый поток;
  • Тип chan, представлющий каналы;
  • Оператор <- для отправки и приема сообщений по каналам;
  • Оператор выбор между каналами alt.

Рассмотрим каждую из них подробнее.

Оператор spawn используется для порождения новых потоков. Сразу после запуска приложения (функции init) оно выполняется в рамках одного потока (в Inferno процесс - это всего-лишь "тяжеловесный" поток). С помощью оператора spawn головной поток может быть разделен на несколько независимых потоков, исполняющих определенную функцию. Например, если мы поместим в модуль HelloWorld следующий код, то получим приложение, распраллеливаемое на 10 потоков, каждый из которых выполняет инструкции функции hello:

init(nil: ref Draw->Context, argv: list of string) { 
    sys = load Sys Sys->PATH; 
    for (i:=i; i<=10; i++) { 
	spawn hello(i); 
    } 
} 
 
hello(i: int) { 
    sys->print("Hello from thread number %d\n", i); 
}

Как видите для создания многонитевого приложения достаточно всего пары строк дополнительного кода и оператора spawn. Но что делать если потоки должны координировать свои действия по мере исполнения? Для этого предназначены каналы.

Каналы Limbo похожи на каналы командного интерпретатора UNIX. Это механизм языка для двунаправленной передачи типизированных данных между потоками. Один поток создает канал и пишет в него каки-либо данные, второй поток читает данные. Если адресат не готов принять данные, то отправитель блокируется.

Для создания канала используется стандартная форма объявления переменных:

ch := chan of int;

Выражение создает канал ch, предназначенный для передачи значений типа int. Не стоит разделять объявление и инициализацию канала, так как перед использованием он так или иначе потребует инициализации.

После создания канал можно начинать использовать. Для этого предназначены операторы отправки и приема сообщений (<-):

ch <- = 100; # отправить в канал ch значение 100 
a = <- ch; # присвоить переменной a значение, полученное из канала ch

В официальной документации Inferno есть пример, наглядно демонстрирующий мощь концепции каналов. Это монитор, используемый для синхронизации паралельной записи в хранилище. Его код приведен ниже:

implement Monitors; 
 
Monitors: module 
{ 
    Monitor: adt { 
	create: fn(): Monitor; 
	lock: fn(m: self Monitor); 
	unlock: fn(m: self Monitor); 
	# канал, который будет использован для синхронизации 
	ch: chan of int; 
    }; 
}; 
 
# Создание монитора 
Monitor.create(): Monitor 
{ 
    m := Monitor(chan of int); 
    spawn lockproc(m.ch); 
    return m; 
} 
 
# Блокировка 
Monitor.lock(m: self Monitor) 
{ 
    m.ch <- = 0; 
} 
 
# Снятие блокировки 
Monitor.unlock(m: self Monitor) 
{ 
    <- m.ch; 
} 
 
# Функция исполняется в отдельном потоке 
lockproc(ch: chan of int) 
{ 
    for (;;) { 
	<- ch;	    # ждем блокировки 
	ch <- = 0;  # ждем разблокировки 
    } 
}

Вы можете протестировать монитор с помощью примерно такого кода:

m = load Mon "monitors.dis"; 
l := Monitor.create(); 
l.lock(); 
    ... 
# Работа с хранилищем 
    ... 
l.unlock();

Модуль эксплуатирует синхронную сущность каналов. После вызова функции lock, в канал, уже созданный функцией create, отправляется сообщение (в данном случае это цифра 0, но это не важно). Поток, исполняющий функцию lockproc, разблокируется, принимает сообщение и вновь блокируется на отправке сообщения в канал. Если после этого вновь вызвать функцию lock, вызывающий поток будет блокирован, потому что lockproc еще не готов принять сообщение из канала (он заблокирован на его оправке). Однако, после вызова unlock, поток lockproc разблокируется и вновь будет жать сообщения из канала, что означает разблокировку потока, вызвавшего lock.

Каналы удобны для реализации самых разных алгоритмов, начиная от таймеров, работающих в отдельном потоке, и систем для асинхронного чтения файлов, и заканчивая серверами, способными породить тысячи потоков. Но что делать когда каналов несколько, а следить за ними необходимо одновременно? В этом случае поможет оператор выбора канала alt.

Оператор выбора канала позволяет решить проблему блокировки, когда из-за блокирования одного канала невозможно обработать второй. Внешне и внутренне он напоминает оператор выбора case, с тем лишь исключением, что предназначен для обслуживания исключительно каналов:

alt { 
    one = <- chan1 => 
	sys->print("Message from channel 1: %s\n", one); 
    two = <- chan2 => 
	sys->print("Message from channel 2: %s\n", two); 
    * => 
	sys->print("No messages\n"); 
}

Благодаря alt на этом участке кода приложение не будет блокировано в любом случае, а если в одном из каналов есть сообщение, то будет отработан закрепленным за ним блок. В случае, если ни в одном канале сообщений нет, будет отработан блок по умолчанию.


Выводы

Limbo - чрезвычайно мощный и простой в освоении язык. Он может похвастать многими уникальными чертами, которых нет в большинстве других языков программирования. История Limbo началась задолго до появления самого языка и насчитывает многие годы исследований в области параллельных ЯП, которые были воплощены Робом Пайком в предшественниках Limbo: языках Squeak, Newsqueak и Alef.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=772961
ArticleTitle=Inferno и Plan 9: Часть 3. Язык Limbo
publish-date=11082011