- Подписка на печатную версию:
- Подписка на электронную версию:
- Подшивки старых номеров журнала (печатные версии)
LXF130:GoogleGo
Материал из Linuxformat.
Версия 06:38, 22 апреля 2011
Содержание |
Go на языке классиков
- Языков программирования и так развелось немало – стоит ли изобретать еще один? Андрей Боровский разбирается, смогли ли в Google найти ответ на этот извечный вопрос.
Не знаю, как вы, а я попрежнему придерживаюсь мысли, что развитие языков программирования происходит под влиянием теоретических идей, а не аппаратных новинок. Абстрактные типы данных, ООП, безопасные указатели, сборка мусора – все это было придумано довольно давно и независимо от прорывов в области оборудования (которое иногда не поспевало за полетом мысли теоретиков). Как это часто бывает в научной среде, стагнация теории заставляет исследователей искать вдохновения в смежных областях. Применительно к языкам программирования, многие обращаются к параллелизму, который теперь стал доступен пользователям домашних компьютеров. По моему глубокому убеждению, многопоточность совершенно ортогональна структуре языка программирования. Многопоточные приложения можно одинаково успешно реализовывать на любом языке, в котором есть концепция подпрограммы, о чем и свидетельствуют многопоточные программы, написанные на C, C++, Java, C#, Pascal и Visual Basic. Тем не менее, разработчики новых языков часто говорят о том, что их детище адаптировано для параллелизма особенно хорошо. Не является исключением и Go. Впрочем, стоит отметить, что для Go подобное утверждение в общем оправдано. Разработчики Go не внесли нового в теорию параллельного программирования, но создали инструмент, который, будучи потенциально так же эффективен, как C, существенно упрощает процесс написания многопоточных программ.
Тень Google
Вряд ли очередной язык заслужил бы столько внимания, если бы не поддержка со стороны Google и лично Роба Пайка [Rob Pike], легендарного участника разработки ОС Unix и Plan 9; от Plan 9 Go унаследовал некоторые особенности. На сайте проекта (http://golang.org) утверждается, что Go планировалось использовать для написания серверных приложений Google (а они, вероятно, принадлежат к самым быстродействующим и стрессоустойчивым приложениям в мире). Если бы Go преуспел в таком качестве, пожалуй, уже этого было бы достаточно, чтобы все забросили C++ и Java. Но, согласно заявлениям разработчиков, «Go еще не созрел для использования в серверных приложениях в широком масштабе, но мы (разработчики) трудимся над этим». Оценить, насколько хорош Go для написания серверов, можно уже сейчас. Webсервер, обслуживающий сайт golang.org – это написанная на Go программа godoc, которая доступна, как и весь пакет Go, любому программисту, работающему под *nix. Последний факт, кстати, заслуживает особого внимания. В настоящее время Go существует для Linux, FreeBSD и Darwin; на создание порта для Windows, по словам разработчиков, не хватает ресурсов. Однако против ОС от Microsoft они, в принципе, ничего не имеют и с радостью примут помощь в переносе Go на самую популярную платформу.
Возвращаясь к программе godoc, отметим, что ее основное назначение – генерировать документацию для программ, написанных на Go. Однако, будучи запущена с ключом --http, программа превращается в webсервер и начинает выдавать не простой текст, а webстраницы (как вам это нравится?).
Найдем пять отличий
Чем же Go отличается от привычных нам языков, таких как C++? Прежде всего, здесь отсутствует понятие класса. Различные проблемы, которые в C++ решаются множественным наследованием, объявлением методовдрузей, шаблонами и другими способами, в Go решаются с помощью интерфейсов.
Массивы в Go всегда передаются по значению (прямо Паскаль какойто). При этом некоторые другие типы данных (например, хэштаблицы, которые тоже являются встроенными типами языка) передаются по ссылке. Концепция указателей существует, но их арифметика не реализована (что естественно для языка, автоматически управляющего памятью). Для экономии циклов процессора и ОЗУ при работе с функциями можно использовать фрагменты массивов (сечения, slices). Между прочим, в стандартизированном Паскале такая возможность тоже есть. Строки также являются встроенным типом, причем неизменяемым (как в Java/C#). Потоки и каналы (средства обмена данными между потоками) также реализованы как встроенные конструкции. Все преобразования типов в Go выполняются явным образом.
Инструментарий
Устанавливать Go лучше всего непосредственно из интернетрепозитория (инструкции вы найдете по адресу http://golang.org/doc/install.html). Для сборки Go вам понадобятся такие утилиты, как bison и gawk. У меня этот процесс закончился обнадеживающим сообщением «0 known bugs; 0 unexpected bugs». Вы, наверное, ждете, что после сборки Go у вас в системе появится команда go, которая и будет вызвать компилятор? Ничего подобного. Прежде всего, разработчики Go считают, что компилятор еще не созрел до уровня общесистемной команды Linux, так что по умолчанию все двоичные файлы установятся в ~/bin (этот каталог необходимо создать либо до, либо в процессе установки Go). Скомандовав ls ~/bin, вы увидите много файлов, но среди них опятьтаки не будет go. Содержимое этой директории будет зависеть от архитектуры вашей системы. На моей amd64 там расположены:
- 6g – компилятор Go для 64битных платформ Intel/AMD (32битный вариант именуется 8g). На вход компилятора подается файл исходных текстов (с расширением go), а на выходе мы получаем объектный код с расширением из одной цифры (той, с которой начинается имя компилятора).
- 6l (и, соответственно, 8l) – компоновщик Go, превращающий объектный код в исполняемый файл (по умолчанию, 6.out или 8.out). Такая необычная система имен берет начало в традициях операционной системы Plan 9 (LXF126/127).
- 6c (8с) – компилятор C, изначально разработанный для Plan 9 (зачем он нужен – непонятно, так как для сборки Go использует GCC). Интересующиеся могут найти документацию по нему здесь: http://plan9.belllabs.com/magic/man2html/1/2c.
- 6a (8a) – ассемблер родом из той же ОС.
- ebnflint – это не русское ругательство в адрес английского пирата, а инструмент для работы с грамматиками в формате EBNF.
- godefs – инструмент для тех, кто портирует среду времени выполнения Go на новые платформы.
- gofmt – программа, форматирующая исходные тексты на Go (расставляет отступы и прочее).
Помимо этого, наличествуют 6nm (аналог nm), 6prof (gprof), 6cov (gcov), уже упомянутый godoc и cgo – команда, предназначенная для создания пакетов Go, вызывающих функции, написанные на C. Наконец, утилита с приятным украинскому уху названием gopack – это всего лишь аналог ar для Go.
Все команды Go высокомерно игнорируют ключи типа --h и --help; при этом manстраницы для них тоже отсутствуют. Получить краткое описание работы той или иной утилиты можно либо с помощью команды
godoc <имя_утилиты>
либо по адресу http://golang.org/cmd/.
Let’s Go!
Ознакомившись с инструментарием, перейдем к знакомству с самим языком программирования. Now (не могу удержаться от англоязычного каламбура) let’s go say Hello World!
Наша первая программа (hello.go) выглядит так: package main
import “fmt” func main() { fmt.Printf(“ Здравствуй, Мир!\n Hello, world!\n”) }
Для компиляции и сборки программы командуем, соответственно,
./6g hello.go ./6l hello.6
а для выполнения –
./6.out
Результат многоязычного приветствия можно увидеть на экранном снимке.
Не стоит относиться к программам «Здравствуй, Мир!» свысока. Внимательный взгляд на них может многое рассказать о языке. Программа начинается с ключевого слова package, которое объявляет пакет main. Package (пакет) является минимальной единицей компиляции в Go. Он также образует отдельное пространство имен. Попросту говоря, вы складываете в пакет определения функций, структур и объявления переменных – возможно, для того, чтобы использовать их потом в других пакетах. Пакеты могут состоять из одного или нескольких исходных файлов. Больше всего пакеты Go похожи на модули Паскаля (концепция заголовочных файлов в Go отсутствует) с той разницей, что файл программы, как мы видели, тоже является пакетом.
Ключевое слово import указывает, что мы хотим импортировать в нашу программу пакет – стандартный fmt, который, помимо прочего, содержит функцию Printf(). Последняя работает как в C, но использует другие спецификаторы формата вывода переменных. Советую ознакомиться с ее описанием (./godoc fmt Printf). Также обратите внимание на то, что идентификаторы элементов, экспортируемых из внешних модулей, всегда начинаются с заглавной буквы.
Далее мы определяем функцию main(). В отличие от C и C++, в языке Go для объявления функций используется специальное ключевое слово func, как в Паскале. Это далеко не единственное сходство между Go и Паскалем, и дело тут не в языковых пристрастиях авторов. Паскалеподобные элементы Go преследуют важную (с точки зрения разработчиков) цель – повысить скорость компиляции. Лаконичная грамматика C заставляет компилятор выполнять много дополнительной работы по выяснению смысла языковых конструкций. Вспомогательные слова Паскаля и Go упрощают работу компилятора (хотя и вынуждают программиста напечатать несколько дополнительных символов). Как и в C, блоки операторов выделяются в Go фигурными скобками, но ; как разделитель операторов не используется. Точнее говоря, грамматика языка ее допускает, но она не является обязательной. Если говорить еще точнее, синтаксический анализатор Go вставляет символ ; в конце каждой строки, которая завершает оператор. Это правило имеет интересные следствия. Если вместо
Вы, конечно, подумали о том, что может произойти, если вместо
for i < 10 {
мы напишем
for i < 10 {
В отличие от оператора
if i<10;
оператор
for i<10;
породит синтаксическую ошибку (а не бесконечный цикл).
Правда, компилятор укажет на ошибку не совсем там, где мы ожидаем, но мы, по крайней мере, будем знать, что в программе чтото не так.
Для создания бесконечного цикла, то есть такого, выход из которого осуществляется с помощью break или os.Exit() (например, цикла обработки сообщений оконной системы) можно использовать конструкцию
for { ... }
func main() {
мы напишем
func main() {
то синтаксический анализатор преобразует этот фрагмент в
func main(); {
что, естественно, приведет к ошибке. Иначе говоря, открывающая скобка всегда должна находится на той же строке, что и заголовок функции. Это же правило относится и к другим операторам, использующим скобки. Например, если вместо
if a > b { Printf(“a > b”) }
написать
if a > b { Printf(“a > b”) }
то функция Printf() будет вызываться всегда, независимо от значений a и b. При этом компилятор сообщит об ошибке, так как выражение
if a > b;
является вполне законным (хотя и бесполезным). Будет выдано только предупреждение. Все это должно радовать последователей Linuxстиля оформления кода на C, однако стоит заметить, что в Go это правило не всегда очевидно и может стать источником трудновыявимых ошибок. Второе следствие заключается в том, что блоки операторов в Go нужно всегда заключать в фигурные скобки, даже если оператор всего один, как в примере выше.
Обратите внимание на то, что перед именем функции Printf() мы указываем имя пакета. Если интуиция программиста подсказывает вам, что существует способ избежать утомительных префиксов, то вы правы. При импортировании пакетов мы можем указать псевдоним, который затем будет использоваться вместо его имени. Например, если бы мы написали
import f “fmt”
то вызов функции Printf() выглядел бы так:
f.Printf(...)
Если же мы напишем
import . “fmt”
функции из пакета fmt можно будет вызывать вообще без префикса.
Еще одна «примочка» Go – использование в идентификаторах символов Unicode. Например:
func main() { var Путь, Время float = 250, 100 var СредняяСкорость float = Путь/Время Printf(“Средняя скорость %f\n”, СредняяСкорость) }
У этого подхода к именованию есть свои сторонники и противники. Я не стану вмешиваться в их спор – замечу только, что с использованием Unicode все не так просто, как кажется на первый взгляд. Во многих алфавитах существуют составные символы, использование которых в Go запрещено. Также, согласно правилам Go, экспортируемые модулем идентификаторы должны начинаться с заглавной буквы, а таковые присутствуют не в каждом алфавите.
Обратите внимание на то, как в Go объявляются переменные. Здесь мы тоже наблюдаем сходство с Паскалем. Объявление начинается с ключевого слова var, а тип переменных следует за перечнем их имен. Если переменная инициализируется в момент объявления, тип можно не указывать. Например, встретив объявление переменной
var i = 10
компилятор сам присвоит переменной i тип int.
Сходство с C заключается в том, что мы можем инициализировать переменные в процессе их объявления. Помимо полной формы, которую мы использовали выше, применяется краткая, с оператором :=. Выражение
i := 10
эквивалентно
var i int = 10
Многопоточность
Простая реализация многопоточности объявлена одним из основных преимуществ языка Go, и естественно, что в этом кратком обзоре мы уделим ей основное внимание. В программе, написанной на Go, очень легко сделать так, чтобы несколько процедур выполнялись одновременно. Разработчики Go называют такие процедуры «goroutines» [игра слов с «coroutines», – прим. ред.]. Не разделяя их склонности к терминотворчеству, я буду применять термин «сопроцедуры». Знакомство с многопоточностью в Go мы начнем с простого, но поучительного примера:
package main import . “fmt” import . “time” func concf(num int) { var i int = 0 for i < 10 { Sleep(100) Printf(“I'm routine %d\n”, num); i++ } } func main() { go concf(1) go concf(2) Sleep(10000000) }
Начнем по порядку. Помимо пакета fmt, в этой программе нам понадобится пакет time. Обратите внимание, что оба они загружаются с одинаковым псевдонимом – таким, что нам не придется добавлять префикс к именам функций. Этот трюк сработает, естественно, только в том случае, если пакеты не экспортируют одинаковых имен.
Сопроцедурой Go может быть любая функция этого языка. В нашем примере это concf(). Функция concf() выполняет цикл for, который синтаксически совсем не похож на одноименные операторы C или Паскаля. На самом деле цикл for в Go является аналогом while, и неудивительно, что последний в Go вообще отсутствует. В цикле функция concf() вызывает функцию Sleep() из пакета time, а затем распечатывает значение своего аргумента. Далее выполняется инкремент управляющей переменной цикла. Обратите внимание на то, что функция Sleep() измеряет интервалы в наносекундах.
В главной функции программы мы дважды вызываем функцию concf(), таким образом, чтобы оба экземпляра выполнялись одновременно. Как вы уже, наверное, поняли, создание нового потока в Go выполняется с помощью конструкции
go <имя_функции>(<параметры>)
После запуска двух дочерних потоков мы вызываем функцию Sleep() – для того, чтобы главная функция не завершилась до окончания выполнения потоков (при выходе из нее программа завершится независимо от того, работают ли дочерние потоки). В результате мы увидим, как оба потока попеременно распечатывают значения своих параметров.
Стоит отметить, что, по утверждению документации, функция Printf() реентерабельна. Учитывая то внимание, которое в Go уделяется многопоточности, можно ожидать, что все функции из стандартных пакетов обладают таким свойством, однако документация молчит об этом.
Разумеется, использование функции Sleep() в расчете на то, что потоки успеют завершится до истечения заданного интервала – не лучший метод управления. Cерьезная многопоточная программа не может обойтись без средств синхронизации.
Средства синхронизации
Хотя все, что поддерживает многопоточность в Go, может быть реализовано и в любом другом развитом языке программирования, инструменты Go действительно очень удобны. Разработчики учли, что обмен данными между потоками всегда требует синхронизации, и потому важнейшее средство обмена данными, каналы, является одновременно и синхронизирующим. Рассмотрим простейший пример программы, использующей каналы для передачи данных и синхронизации.
Изза особенностей типа string мы не можем написать
s < c
– нам приходится использовать оператор присваивания; однако, если бы переменная s имела простой тип, например, int, такая запись была бы вполне допустима. Конструкция = < введет в ступор любого программиста, незнакомого с Go, и, возможно, повысит вашу хакерскую репутацию (хотя обратный эффект тоже не исключается).
package main import . “fmt” import . “time” var c = make(chan string, 1) func writer() { Printf(“Writer started\n”) Sleep(100000000) c < “Hello from writer” } func main() { var s string go writer() Printf(“Writer is called\n”) s = < c Printf(“%s\n”, s) }
Каналом в нашей программе является переменная c типа «канал» (chan). Он относится к сложным типам, поэтому для инициализации c используется функция make(), с которой мы еще встретимся далее. Сейчас я просто скажу вам, что мы создаем буферизованный канал для передачи переменных типа string. Функция writer записывает в канал строку (вызов Sleep() добавлен для наглядности). Запись данных в канал и чтение из него выполняются с помощью оператора <, который чемто похож на << в С++.
Функция main() вызывает writer() в режиме сопроцедуры, а затем читает данные из канала. Важное свойство буферизованных каналов заключается в том, что чтение начинается после того, как заканчивается запись. Таким образом мы гарантируем, что выполнение программы закончится только после того, как writer() запишет данные в канал, а main() прочтет их и распечатает. Строка «Hello from writer» будет последней, которую выведет наша программа. Обратите внимание, что последовательность вывода на печать строк «Writer is called» и «Writer started» непредсказуема, поскольку в этом случае мы не позаботились о синхронизации.
Каналы как средство синхронизации потоков настолько удобны, что их иногда используют исключительно с этой целью (создается буферизованный канал для переменных типа int или byte, а затем запись и чтение значения соответствующего типа применяются в роли сигналов об окончании некоей операции).
Тем не менее, каналы – не единственное средство синхронизации потоков в Go.
package main import . “fmt” import . “sync” var m1, m2 Mutex var i int = 0 var c = make(chan int, 1) func finish() { c < 1; } func join(num int) { for i < num { i = i + < c } } func printer1() { m1.Lock(); Printf(“This is ”) m2.Unlock() m1.Lock() Printf(“that Jack ”) m2.Unlock() finish() } func printer2() { m2.Lock(); Printf(“the house ”) m1.Unlock() m2.Lock() Printf(“built\n”) m1.Unlock() finish() } func main() { m2.Lock() go printer2() go printer1() join(2) }
Эта программа использует два потока и мьютексы для вывода на печать строки из детского стишка. Объект Mutex определен в пакете sync. Для тех, кто не знает, как работает мьютекс, упрощенное объяснение: если поток А вызывает метод m.Lock() мьютекса m, а затем поток Б также вызывает метод m.Lock(), выполнение Б будет приостановлено до тех пор, пока А не вызовет m.Unlock(). Введенные мной функции finish() и join() позволяют программе дождаться завершения всех сопроцедур. Перед выходом из сопроцедуры мы вызываем функцию finish(), а вызов функции join(n) приостанавливает выполнение вызвавшей ее процедуры до тех пор, пока n сопроцедур не вызовут finish(). Функции finish() и join() используют буферизованные каналы в роли семафоров (как это часто делается в Go).
Как ни странно, я не нашел аналога этих удобных функций в документации по Go, так что прошу рассматривать их как мой собственный вклад в развитие среды времени выполнения этого языка.
На закуску предлагаю вам самостоятельно разобрать текст программы на Go, которая использует графический интерфейс X11 (рис. 4). Вы найдете его на диске в файле xwindows.go – это один из примеров от авторов Go, слегка модифицированный мной. Результат его работы (причем в окне Microsoft Windows!) можно видеть на рисунке.
Взаимные блокировки
Зкспериментируя с сопроцедурами, я обнаружил интересную вещь. Если нарочно заблокировать с помощью мьютексов выполнение всех сопроцедур, программа не повиснет, а завершится аварийно, с сообщением “throw: all goroutines are asleep deadlock”! Наличие «сторожевой собаки», проверяющей взаимные блокировки, безусловно, интересная и полезная функция Go, но ее возможности не следует преувеличивать. Как известно, невозможно написать программу, способную проверить правильность любой другой: «умелые руки» всегда найдут способ поставить в тупик искусственный интеллект.