Статьи

Сі на стероїдах: знайомимося з мовою програмування Go

  1. Зміст статті Ми звикли думати, що по-справжньому універсальних мов програмування не існує. Коли...
  2. продуктивність
  3. Простота розробки і супроводу
  4. Засоби паралельного програмування
  5. приклад
  6. Стривайте, але ж це не многопоточность?
  7. Висновки

Зміст статті

Ми звикли думати, що по-справжньому універсальних мов програмування не існує. Коли нам потрібна ефективність - ми пишемо на Сі і миримося з його обмеженнями. Коли потрібна швидкість розробки - Кодима на 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 входять плагіни для всіх популярних середовищ програмування, в тому числі 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

Що все це означає на ділі?
Просто, чи не так?
Але як це буде виглядати в коді?
Стривайте, але ж це не многопоточность?

Новости