Мы привыкли думать, что по-настоящему универсальных языков программирования не существует. Когда нам нужна эффективность — мы пишем на Си и миримся с его ограничениями. Когда нужна скорость разработки — кодим на Python и ожидаем получить медленный код. Erlang позволяет создавать высокораспараллеленные распределенные приложения, но его очень трудно вписать в существующие проекты. Язык Go полностью ломает такую систему мышления, сочетая в себе преимущества многих языков и освобождая программистов от их недостатков.

Когда десять лет назад Кена Томпсона, принимавшего активное участие в разработке языка Си, спросили, каким бы он сделал этот язык на тот момент, он ответил, что язык был бы похож на Limbo. Прошло немало времени, и Томпсон совместно с еще одним автором языка Си, Робом Пайком, принял участие в создании Go — языка, который стал переосмыслением и последующим развитием Limbo. Go был представлен миру 10 ноября 2009 года и практически сразу стал бестселлером. Одни только имена авторов, известных как создатели операционной системы UNIX, языка программирования Си и кодировки UTF-8, а также покровительство Google, в лабораториях которых был создан язык, дали Go отличный старт. Однако даже это не позволило бы языку долго продержаться на плаву, если бы он не смог предложить программистам что-то действительно новое — что-то, что упростило бы их жизнь и сделало Go по-настоящему незаменимым. И это «что-то» в языке было. В большом количестве.

 

Си сегодняшнего дня

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

В основу Go положено три фундаментальных идеи:

  1. Гарантия высокой скорости компиляции и производительности приложений.
  2. Простота разработки и поддержки приложений, свойственная высокоуровневым скриптовым языкам.
  3. Встроенные средства параллельного программирования, позволяющие задействовать все имеющиеся ядра современных процессоров.

Что все это значит на деле? Разберемся с каждым из пунктов.

 

Производительность

Даже очень простая референсная реализация компилятора с языка 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 эта идея выглядит так:

  1. Создание переменной-канала.
  2. Определение функции, которая принимает переменную-канал в качестве аргумента, а в своем теле содержит код, который должен быть выполнен в отдельной нити. В конце функция должна отправить результат своего выполнения в канал (это делается с помощью специального оператора).
  3. Запуск функции в отдельном потоке с помощью ключевого слова «go».
  4. Чтение из канала.

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

 

Пример

Один из моих любимых примеров, демонстрирующих мощь языка 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 располагает рядом средств.

  1. Каналы можно проверять на наличие сообщений. Если строку «str = <-ch» заменить на «str, ok = <-ch«, то головной поток не будет заблокирован, даже если в канале нет сообщения. Вместо этого в переменную ok будет записано значение false и работа потока продолжится. Естественно, дальше можно поместить проверку на «ok == false» и успеть выполнить какую-то полезную работу, а затем начать новую итерацию цикла и вновь попробовать получить значение. Кстати, таким же образом можно выполнить проверку в потокеотправителе:

ok := ch <- "Продолжаем"

  1. Каналы в Go могут быть буферизированы, то есть уметь накапливать определенное количество сообщений до того, как отсылающая сторона будет заблокирована. Для этого достаточно всего лишь добавить один дополнительный аргумент в вызов функции make:

ch := make(chan string, 10) // создать канал с буфером в 10 позиций

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

  1. В программу можно добавить одну или несколько функций и отправить их в отдельные потоки, тогда они будут спокойно работать совершенно параллельно, а когда таймер «прозвенит» заданное количество раз, все они будут уничтожены вместе с головным потоком. Однако, если мы захотим получить результат от этих потоков через канал, то опять упремся в блокировки либо будем вынуждены делать множество проверок на наличие сообщений в каналах, как описано в первом пункте. Но этого можно избежать, если применить «оператор выбора потоков»:

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

2 комментария

  1. 20.01.2015 at 20:52

    Спасибо за статью.

  2. 23.03.2015 at 02:04

    Вот этот абзац
    1. Каналы можно проверять на наличие сообщений. Если строку “str = <-ch” заменить на “str, ok = <-ch“,
    то головной поток не будет заблокирован, даже если в канале нет
    сообщения. Вместо этого в переменную ok будет записано значение false и
    работа потока продолжится. Естественно, дальше можно поместить проверку
    на “ok == false” и успеть выполнить какую-то полезную
    работу, а затем начать новую итерацию цикла и вновь попробовать получить
    значение. Кстати, таким же образом можно выполнить проверку в
    потокеотправителе:

    работает только в

    select {
    case str, ok := <- ch:
    //

    default:
    //

    }

    А раз уж добавляем select, то и не нужен ok.
    Поправьте меня плз если не прав.

Оставить мнение

Check Also

WWW: Netsim — игра, которая поможет изучить работу сетей и принципы атак

Тем, кто только начал разбираться с хакерской кухней, не помешает узнать, как работают сет…