Haskell for fast, concurrent, robust services

Michael Snoyman, Director of Engineering, FP Complete

Twitter tech talk, November 10, 2015

What is Haskell?

  • Strongly and statically typed
  • Purely functional/explicit side effects
  • Immutable by default
  • Compiles to native code
  • Multithreaded runtime
  • Playground for interesting optimizations (rewrite rules)

What we'll cover today

What makes Haskell great for writing network services?

  • Efficient single-core performance
  • Powerful concurrency story
  • Great abstractions
  • Tools for writing robust code
  • Some miscellaneous goodness
  • How to get started

Single Core Performance

Tuned for immutable data

  • Haskell encourages immutable data
  • Garbage collector highly tuned for such workloads (generational, copying)
  • New data goes into a nursery
  • Temporary data gets garbage collected quickly

Data Unpacking

How much memory does it take to store the following? (Assume 64-bit)

data Foo = Foo Int Int

Foo constructor (8 bytes), 2 * (pointer to Int (8), Int constructor (8), raw Int (8)) == 56 bytes

data Foo = Foo {-# UNPACK #-} !Int {-# UNPACK #-} !Int

Foo constructor (8), 2 * (raw Int (8)) == 24 bytes

Note: UNPACK pragmas aren't necessary for small data types, just strictness (!)

Nested unpacked data

Unpacking also works for larger values!

data Foo = Foo !Int !Int
data Bar = Bar {-# UNPACK #-} !Foo !Int
  • For now, only works for single-constructor data types
  • Do you want lazy or strict data?
  • In practice: strict data often a good choice
  • Side benefits: much harder to create a space leak

Vectors

  • Contiguous memory collections
  • Avoid cons-cell overhead
  • Think: std::list vs std::vector
  • Both mutable and immutable variants
  • Implement mutable algorithms safely via ST monad
  • Boxed, unboxed, and storable (C-FFI compatible) representations

Vectors (cont)

Stream fusion

How much memory does the following code use?

import qualified Data.Vector.Unboxed as V
main = print $ V.sum $ V.enumFromTo 1 (10^9 :: Int)
  • Without optimizations: ~8GB
  • With optimizations: ~52KB
  • GHC optimizes this down to a tight inner loop without any actual vector
  • Is GHC itself actually that smart? No, not really...

Rewrite rules

  • Tell GHC that certain expressions can be rewritten as something else
  • map f . map g = map (f . g)
  • Takes advantage of Haskell's purity
  • Provides library authors with ability to create new optimizations without hacking on the compiler
  • Typically promotes lots of inlining (downside: optimized executables tend to be large)

Demonstration: efficient statistics calculation

Stepping away from the slides for a moment

Builders

  • Buffer-filling actions for creating byte arrays and text
  • Minimal buffer copying
  • Composable (via Monoid)
  • Easy to do the right thing
  • Highly tuned by real-world data and testing
  • Good trade-off between memory copying and extra syscalls (e.g., Warp HTTP server)

Great example libraries

Concurrency

Green threads

  • Lightweight threads in the Haskell runtime
  • Mapped to actual system threads/cores by scheduler
  • Uses evented (per-OS) system calls to wake up threads
  • Result: code against blocking APIs, callback magic happens for you automatically!

Low-level example

{-# LANGUAGE OverloadedStrings #-}
import Data.Streaming.Network
import Control.Monad
import Control.Concurrent

main :: IO ()
main = runTCPServer (serverSettingsTCP 4500 "*") $ \ad ->
    forever $ do
        bs <- appRead ad
        print $ "Received: " ++ show bs
        appWrite ad "Now I'm going to delay...\n"
        threadDelay 1000000

Immutable-by-default

  • Shared mutable state is hard
  • Make it easy: data is immutable by default!
  • Mutable variables available as necessary
  • Explicitly decide what kind of mutable variables you want (IORef, MVar, TVar)
  • Focus your attention on the few mutable pieces of the system

Explicit side effects

  • Function of type Int -> String does not modify the world
  • Allows for some almost-free parallelism (e.g., parMap)
  • Also opens the door for Software Transactional Memory (STM)
  • Not to mention: makes your code easier to understand and maintain

STM example

import Control.Concurrent.STM
import Control.Concurrent.Async
main = do
    alice <- newTVarIO 50
    bob <- newTVarIO 50
    runConcurrently $
        Concurrently (transfer 60 alice bob) *>
        Concurrently (transfer 30 bob alice) *>
        Concurrently (transfer 40 bob alice)
  where
    transfer amt srcVar dstVar = atomically $ do
        modifyTVar dstVar (+ amt) -- yes, even this position works!
        srcOrig <- readTVar srcVar
        let srcNew = srcOrig - amt
        check (srcNew >= 0)
        writeTVar srcVar srcNew

The async package

  • Futures on steroids
  • Codifies many best practices
  • Handles cases you didn't even know you had to worry about (like exceptions)
  • Simple helper functions like race and both
  • Applicative interface (via Concurrency)

Network-based concurrency

Abstractions

Mathematical type classes

  • Laws allow you to reason about code
  • Can also expose optimizations opportunities
  • Example:
    • monoidal append is associative
    • builders prefer right-association
    • we know it's always safe to make that transformation

Commonly used type classes

  • Functor/Applicative/Monad
  • Semigroup/Monoid
  • Foldable/Traversable
  • But wait, there's more! Typeclassopedia

Streaming data

  • conduit and pipes libraries
  • Authors of each are in this room
  • Focus on your algorithm, not the low-level details of moving data around
  • Highly composable

Conduit example

import Conduit
import qualified Data.Text as T

main :: IO ()
main = stdinC
    $$ mapC T.toUpper
    =$ filterCE (/= 'E')
    =$ stdoutC

Robust

  • Static/strong typing makes your code easier to refactor and maintain
  • Restricted side effects makes your code easier to test, and encourages a good coding style
  • Immutable data structures avoid common problems like race conditions and deadlocks

Miscellaenous goodness

  • Parametric polymorphism
  • Avoid boilerplate! Generics, Scrap your Boilerplate, Template Haskell
  • Test with confidence: QuickCheck, tasty, hspec

Getting started

Write scripts!

Easy way to get started

#!/usr/bin/env stack
-- stack --resolver lts-3.2 --install-ghc runghc --package turtle
{-# LANGUAGE OverloadedStrings #-}
import Turtle
main = echo "Hello World!"

Questions

Thanks for being a great audeince