Содержание статьи
Мы привыкли думать, что по-настоящему универсальных языков программирования не существует. Когда нам нужна эффективность — мы пишем на Си и миримся с его ограничениями. Когда нужна скорость разработки — кодим на Python и ожидаем получить медленный код. Erlang позволяет создавать высокораспараллеленные распределенные приложения, но его очень трудно вписать в существующие проекты. Язык Go полностью ломает такую систему мышления, сочетая в себе преимущества многих языков и освобождая программистов от их недостатков.
Когда десять лет назад Кена Томпсона, принимавшего активное участие в разработке языка Си, спросили, каким бы он сделал этот язык на тот момент, он ответил, что язык был бы похож на Limbo. Прошло немало времени, и Томпсон совместно с еще одним автором языка Си, Робом Пайком, принял участие в создании Go — языка, который стал переосмыслением и последующим развитием Limbo. Go был представлен миру 10 ноября 2009 года и практически сразу стал бестселлером. Одни только имена авторов, известных как создатели операционной системы UNIX, языка программирования Си и кодировки UTF-8, а также покровительство Google, в лабораториях которых был создан язык, дали Go отличный старт. Однако даже это не позволило бы языку долго продержаться на плаву, если бы он не смог предложить программистам что-то действительно новое — что-то, что упростило бы их жизнь и сделало Go по-настоящему незаменимым. И это "что-то" в языке было. В большом количестве.
Си сегодняшнего дня
Создатели Go позиционируют свое детище как системный язык, сочетающий в себе эффективность и скорость исполнения кода, написанного на Си, с простотой разработки на более высокоуровневых скриптовых языках, да еще и со встроенными средствами параллельного программирования. При этом внешне Go напоминает какую-то странную солянку из синтаксисов языков Си, Pascal и ADA, что вкупе с приведенным описанием создает довольно сильное ощущение подвоха, почти такое же, какое возникает, когда слышишь о новой мега-разработке пятигорских студентов. Однако оно быстро убывает, когда ты начинаешь изучать язык, и совсем улетучивается, когда узнаешь о том, почему Go стал именно таким, какой он есть.
В основу Go положено три фундаментальных идеи:
- Гарантия высокой скорости компиляции и производительности приложений.
- Простота разработки и поддержки приложений, свойственная высокоуровневым скриптовым языкам.
- Встроенные средства параллельного программирования, позволяющие задействовать все имеющиеся ядра современных процессоров.
Что все это значит на деле? Разберемся с каждым из пунктов.
Производительность
Даже очень простая референсная реализация компилятора с языка Go способна за какие-то доли секунды сгенерировать на удивление быстрый код, скорость исполнения которого будет сопоставима со скоростью исполнения кода, написанного на таких языках, как Си и C++. При этом, в отличие от своих предков, компилятор Go гарантирует проверку типов, а результирующий код получает встроенный сборщик мусора и собственный механизм распараллеливания.
С самого начала язык проектировался таким образом, чтобы быть легко понятным и простым в "переваривании" не только человеку, но и машине. Многие синтаксические и архитектурные элементы Go были задуманы если и не с главной целью, то, по крайней мере, с оглядкой на возможность их простого разбора программой, будь то компилятор, дебаггер или даже среда разработки. Язык получился очень прямолинейным и недопускающим неочевидностей и спорных мест, которые могли бы привести компилятор в замешательство (язык C++ — яркий пример такого неочевидного синтаксиса и общей механики, которые заставляют головы программистов трещать, а компилятор — медленно буксовать на месте).
Многие другие элементы языка, не имеющие прямого отношения к синтаксису, также были оптимизированы заранее. Например, язык не имеет механизма неявного приведения типов, что защищает программиста от ошибок и позволяет сделать компилятор проще. В языке нет полноценной реализации классов с их наследованием и полиморфизмом. Механизм параллельного программирования использует собственную реализацию потоков внутри каждой программы, делающую потоки настолько легкими, что их создание обходится практически даром. Встроенный сборщик мусора также весьма проворен, в языке просто нет элементов, которые могли бы усложнить его работу.
В стандартную поставку Go входят плагины для всех популярных сред программирования, в том числе Vim
Простота разработки и сопровождения
Go — системный язык, что, тем не менее, не мешает ему быть достаточно высокоуровневым для того, чтобы обеспечить программиста всем необходимым для комфортного и быстрого написания кода. Язык включает в себя такие высокоуровневые конструкции, как ассоциативные массивы и строки (которые можно сравнивать, копировать, вычислять длину, делать срезы). Он имеет средства для создания собственных типов данных (подобных классам в других языках), средства создания потоков и обмена данными между ними, и, конечно же, он лишен указателей, способных ссылаться на любой участок памяти (срыв стека в программе, написанной на Go, невозможен в принципе). Однако главное, что дает Go программисту, это та самая прямолинейность и очевидность синтаксиса, о которой мы говорили в предыдущем разделе. В этом смысле Go очень похож на языки Pascal, Modula и Oberon: практически любой синтаксический элемент языка следует общей логике и может быть явно и безошибочно интерпретирован вне зависимости от его положения в коде. Например, совершить знаменитую ошибку объявления переменных, описанную во всех гайдах по стилистике оформления кода на языке Си, в Go просто невозможно:
int* a, b; // В Си и C++ переменная "a" будет указателем, но "b" — нет
var a, b *int; // В Go обе переменные будут указателями
Go — язык, созданный программистами и для программистов. Это проявляется во всем, начиная от обрамления блоков кода в стиле Си, неявного объявления типов, отсутствии необходимости ставить точку с запятой после каждого выражения и заканчивая такими архитектурными решениями, как отсутствие механизма исключений и полноценных классов (они были созданы для упрощения жизни, но вместо этого приводят к запутыванию кода). Основная идея языка в том, чтобы быть инструментом, который позволяет писать программы, вместо того, чтобы думать о том, заработают ли они вообще (эта черта свойственна Си и, в еще большей степени, C++).
Средства параллельного программирования
Встроенные средства параллельного программирования — это самая сильная черта Go, и здесь среди языков общего назначения ему просто нет равных (за исключением разве что Limbo, но он привязан к ОС Inferno). И выигрыш здесь не столько в том, что эти средства встроены в сам язык, сколько в том, что они реализуют очень простую и эффективную модель, полностью следующую теории взаимодействующих последовательных процессов (CSP). Читатели, знакомые с Occam и Limbo, должны хорошо понимать все преимущества CSP, а для остальных поясню. Вместо того, чтобы городить огород из потоков, блокировок, мьютексов и прочих систем синхронизации, которые делают параллельное программирование невыносимой мукой и приводят к изданию многостраничных книг о том, как писать многопоточные приложения, автор CSP Тони Хоар предлагает простое и элегантное решение: позволить приложению в любой момент создать новую нить, которая сможет общаться с родителем и другими нитями с помощью отправки синхронных сообщений.
В Go эта идея выглядит так:
- Создание переменной-канала.
- Определение функции, которая принимает переменную-канал в качестве аргумента, а в своем теле содержит код, который должен быть выполнен в отдельной нити. В конце функция должна отправить результат своего выполнения в канал (это делается с помощью специального оператора).
- Запуск функции в отдельном потоке с помощью ключевого слова "go".
- Чтение из канала.
Функция ответвляется от основного потока исполнения, который в это время переходит к ожиданию данных в канале, результат исполнения функции отправляется в канал и основной поток получает его. Просто, не так ли? Но как это будет выглядеть в коде?
Пример
Один из моих любимых примеров, демонстрирующих мощь языка Go, — это реализация таймера, который выполняется в отдельном потоке и "стучит" основному потоку через определенные интервалы времени, в течение которых уходит в сон. Код этой программы, написанный на одном из "классических" языков программирования, выглядел бы громоздким и запутанным, но Go позволяет сделать его простым и красивым.
Код нашей программы:
1 package main
2
3 import "time"
4 import "fmt"
5
6 func timer(ch chan string, ns, count int) {
7 for j := 1; j <= count; j++ {
8 time.Sleep(int64(ns))
9 if j == count {
10 fmt.Printf("[timer] Отправляю последнее сообщение...n")
11 ch <- "стоп!"
12 } else {
13 fmt.Printf("[timer] Отправляю...n")
14 ch <- "продолжаем"
15 }
16 fmt.Printf("[timer] Отправил!n")
17 }
18 }
19
20 func main() {
21 var str string
22
23 ch := make(chan string)
24 go timer(ch, 1000000000, 10)
25
26 for {
27 fmt.Printf("[main] Принимаю...n")
28 str = <-ch
29 if str == "стоп!" {
30 fmt.Printf("[main] Принял последнее сообщение, завершаю работу.n")
31 return
32 } else {
33 fmt.Printf("[main] Принято!n")
34 }
35 }
36 }
Простейшая реализация этой программы заняла бы пятнадцать строк, но я намеренно усложнил ее, добавив вывод на терминал и условные выражения. Они помогут понять общий синтаксис языка и механизм работы планировщика потоков Go. Вывод команды приведен на скриншоте.
Результат работы программы после пяти итераций цикла
На первый взгляд листинг очень напоминает код программы, написанной на языке Си, C++ или даже Java, но при более детальном изучении становятся видны различия — Go унаследовал от Си только базовый синтаксис, в то время как большинство ключевых слов и лексика изменились. Исходный код начинается с ключевого слова package, следом за которым идет имя пакета, к которому этот код относится. Все запускаемые пользователем программы должны иметь имя main, тогда как библиотеки могут иметь произвольное имя, которое будет использовано для доступа к ее функциям и переменным после импортирования. При этом для пометки, должна ли функция или переменная быть экспортируемой, используется верхний регистр: все объекты, имена которых начинаются с большой буквы, будут экспортированы, остальные останутся приватными.
В строках 3 и 4 происходит импортирование пакетов time и fmt, функции которых понадобятся нам позже. Импортирование пакетов во многом очень похоже на включение в программу заголовочных файлов, как это делается в Си и C++, с тем исключением, что Go, во-первых, следит за пространством имен и все импортированные функции, переменные и типы данных будут иметь префикс в виде имени пакета, а во-вторых, не требует наличия самих заголовочных файлов. Никакой возни с хидерами и пространством имен!
Со строки 6 начинается описание функции timer() нашего главного действующего лица. В последующем коде она будет отправлена в отдельный поток, и большую часть времени проведет во сне, а просыпаясь, будет отчитываться головному потоку. Чтобы сделать это, ей нужен доступ к каналу, поэтому первый аргумент функции — это ch типа "канал для передачи строк". Также ей нужно знать временной отрезок, в течение которого она может спать, и то, сколько раз она сможет это сделать. Поэтому второй и третий аргументы — это ns и count типа int. Обрати внимание на форму описания аргументов. В отличие от Си, в Go сначала идет имя переменной, и лишь после — ее тип (что логично и согласуется с системой мышления человека: "переменная такая-то такого-то типа"). Тип возвращаемого функцией значения в Go следует помещать в конец, сразу за закрывающей скобкой (что, кстати, тоже логично). При этом, если функция должна возвращать несколько значений (в Go это возможно), их типы и (опционально) имена должны быть перечислены через запятую и обрамлены скобками. У нашей функции возвращаемого значения нет — уйдя в отдельный поток, она так или иначе ничего вернуть не сможет. Функция должна повторить процедуру "сон — отчет" указанное в переменной count число раз, поэтому в строке 7 начинается цикл for, запись которого полностью аналогична своему собрату в языке Си, за исключением отсутствия скобок.
Чтобы отправить поток timer в сон мы используем функцию Sleep (строка 8) из ранее импортированного пакета time. Ее аргумент, задающий длительность сна, должен иметь тип int64 (аналогичный типу long в Си), поэтому мы должны использовать приведение типов, компилятор не сделает это за нас (и правильно, мы умнее). Чтобы головной поток знал, когда поток timer завершится, и смог обработать эту ситуацию, timer должен предупредить его. Поэтому в строках с 9 по 15 происходит проверка на достижение максимального числа повторений сна. Для этого используется стандартный оператор if, который со времен Си остался неизменным, но так же, как и for, потерял скобки. Если это последнее повторение, на экран выводится "Отправляю последнее сообщение...", а в канал поступает сообщение "Стоп!", в противном случае на экране появится "Отправляю сообщения...", а в канал пойдет "Продолжаем". Каналы в Go типизированы, поэтому в канал ch, объявленный с типом chan string, можно отправить только строку (проверка типов в Go осуществляется во время компиляции, поэтому ошибки легко отловить). В строке 16 поток подтверждает отправку сообщения с помощью печати строки "Отправил!" на экран.
Как и в Си, в Go индикатором начала программы является функция main (строки с 20 по 36), в рамках которой будет выполняться основной поток. Все, что должна сделать наша функция main, это создать новый канал, передать его функции timer, отправить его в отдельный поток и ждать результатов.
Чтобы получать сообщения из канала, понадобится переменная-приемник. Эту роль будет выполнять переменная str типа string, объявленная в начале функции с помощью ключевого слова var (ее значением автоматически станет nil, что эквивалентно NULL в Си). Для создания канала используется встроенная функция make() (строка 23), которая просто выделяет память под указанный тип данных и инициализирует его нулевым значением. Кроме каналов с помощью make можно создавать ассоциативные массивы и срезы, для выделения памяти используется new(). Мы не можем просто объявить переменную типа chan string и работать с ней, потому что буфер, используемый для хранения передаваемых по каналу данных, не будет выделен. Также обрати внимание на неявное объявление переменной ch, которое происходит с помощью оператора ":=" (типизация при этом сохраняется, переменная будет иметь тип присваиваемого значения). В строке 24 timer наконец-то отправляется в отдельный поток. Причем делается это с помощью одного-единственного ключевого слова — go.
Теперь, когда timer был отправлен выполнять свое задание, головному потоку остается только ждать сообщений. Для приема сообщений из потока в Go используется уже описанный ранее оператор "<-", который теперь следует направить "из потока в принимающую переменную":
str = <-ch
Но если бы мы добавили в код только одну эту строку, то головной поток продолжил бы работать после получения первого сообщения и в конце концов завершился, не обработав остальные сообщения. Поэтому нам нужен бесконечный цикл. Он занимает строки с 26 по 35. Go не имеет в своем составе "настоящего" while, поэтому, если требуется создать условный оператор цикла, то следует просто поместить условие после ключевого слова for и не париться (или вообще ничего не указывать, как это сделал я).
При каждой итерации цикла в переменную str будет записываться сообщение, пришедшее от потока timer, и, в зависимости от содержимого сообщения, будет выбираться тот или иной вариант дальнейших действий. Обрати внимание, язык позволяет спокойно сравнивать строки без всяких подсобных функций. Кроме того, ты можешь получать их срезы и копии (на манер python или ruby) и вычислять длину с помощью ключевого слова len (все это справедливо и в отношении массивов).
Для запуска программы потребуется компилятор, который можно скачать с официального сайта языка (правда пока доступны только версии для UNIX, Plan9 и MacOS X). Если ставить его не хочется (или у тебя Windows), программу можно запустить, используя специальную форму на сайте golang.org (правда, из-за ограничения на длительность работы программы продолжительность сна потока timer придется сильно сократить). Это все.
Постойте, но ведь это не многопоточность?
Да, ты наверняка заметил, что из-за блокировок каналов даже на многоядерном процессоре одновременно активным будет только один поток нашей программы, тогда как другой будет ждать отправку/прием сообщения. Это действительно так, и для решения этой проблемы Go располагает рядом средств.
- Каналы можно проверять на наличие сообщений. Если строку "
str = <-ch
" заменить на "str, ok = <-ch
", то головной поток не будет заблокирован, даже если в канале нет сообщения. Вместо этого в переменную ok будет записано значение false и работа потока продолжится. Естественно, дальше можно поместить проверку на "ok == false
" и успеть выполнить какую-то полезную работу, а затем начать новую итерацию цикла и вновь попробовать получить значение. Кстати, таким же образом можно выполнить проверку в потокеотправителе:
ok := ch <- "Продолжаем"
- Каналы в Go могут быть буферизированы, то есть уметь накапливать определенное количество сообщений до того, как отсылающая сторона будет заблокирована. Для этого достаточно всего лишь добавить один дополнительный аргумент в вызов функции make:
ch := make(chan string, 10) // создать канал с буфером в 10 позиций
Замечу, однако, что в нашей программе это не даст результата. Во время сна поток timer не сможет заполнить канал сообщениями единомоментно, так как после каждого его засыпания управление все равно будет переходить головному потоку.
- В программу можно добавить одну или несколько функций и отправить их в отдельные потоки, тогда они будут спокойно работать совершенно параллельно, а когда таймер "прозвенит" заданное количество раз, все они будут уничтожены вместе с головным потоком. Однако, если мы захотим получить результат от этих потоков через канал, то опять упремся в блокировки либо будем вынуждены делать множество проверок на наличие сообщений в каналах, как описано в первом пункте. Но этого можно избежать, если применить "оператор выбора потоков":
select {
case str = <-ch1:
// обрабатываем сообщение от первого потока
case str = <-ch2:
// обрабатываем сообщение от второго потока
case str = <-ch3:
// обрабатываем сообщение от третьего потока
}
Программа будет заблокирована на операторе select до того момента, пока в одном из каналов не появится сообщение. После этого будет выполнен соответствующий блок. Чтобы после обработки одного сообщения select вновь переходил к прослушке каналов, его следует поместить внутрь бесконечного цикла. При этом, если в моменты между поступлениями сообщений поток должен выполнять какую-то работу, то ее следует поместить в блок default внутри select.
Оператор select очень широко используется в Go-программировании, именно с его помощью принято создавать "диспетчеры сообщений", которые разветвляют программу на множество потоков сразу после старта, а затем входят в бесконечный цикл и начинают обработку пришедших от них сообщений. В операционной системе Inferno (все приложения которой написаны на Go-предке Limbo) таким образом реализован многооконный графический интерфейс.
Выводы
Go пока еще молодой, но очень перспективный язык, при создании которого был учтен более чем тридцатилетний опыт в области разработки операционных систем и языков программирования (Роб Пайк двадцать лет занимался исследованиями в области многопоточного программирования, в течение которых были созданы языки Squeak, NewSqueak и Limbo). Go производителен, дружелюбен к программистам и красив.
Освоив этот язык, ты сможешь писать программы, которые будут гораздо эффективнее всего того, что написано на традиционных языках программирования.
Links
- Go FAQ: golang.org/doc/go_faq.html.
- Руководство Go-программиста: golang.org/doc/go_tutorial.html.
- Руководство по эффективному Go-программированию: golang.org/doc/effective_go.html.
- Как Go управляет памятью: golang.org/doc/go_mem.html.