2{-# LANGUAGE ConstraintKinds #-}
3{-# LANGUAGE FlexibleContexts #-}
4{-# LANGUAGE FlexibleInstances #-}
5{-# LANGUAGE GeneralizedNewtypeDeriving#-}
6{-# LANGUAGE InstanceSigs #-}
7{-# LANGUAGE LambdaCase #-}
8{-# LANGUAGE MultiParamTypeClasses #-}
9{-# LANGUAGE ScopedTypeVariables #-}
10{-# LANGUAGE UndecidableInstances #-} -- XXX
13-- Module : Streamly.Streams.Async
14-- Copyright : (c) 2017 Harendra Kumar
17-- Maintainer : harendra.kumar@gmail.com
18-- Stability : experimental
22module Streamly.Streams.Async
39import Control.Concurrent (myThreadId)
40import Control.Monad (ap)
41import Control.Monad.Base (MonadBase(..), liftBaseDefault)
42import Control.Monad.Catch (MonadThrow, throwM)
43import Control.Concurrent.MVar (newEmptyMVar)
44-- import Control.Monad.Error.Class (MonadError(..))
45import Control.Monad.IO.Class (MonadIO(..))
46import Control.Monad.Reader.Class (MonadReader(..))
47import Control.Monad.State.Class (MonadState(..))
48import Control.Monad.Trans.Class (MonadTrans(lift))
49import Data.Concurrent.Queue.MichaelScott (LinkedQueue, newQ, nullQ, tryPopR)
50import Data.IORef (IORef, newIORef, readIORef)
51import Data.Maybe (fromJust)
52import Data.Semigroup (Semigroup(..))
54import Prelude hiding (map)
55import qualified Data.Set as S
57import Streamly.Streams.SVar (fromSVar)
58import Streamly.Streams.Serial (map)
60import Streamly.Streams.StreamK (IsStream(..), Stream, mkStream, unStream, adapt,
61 unStreamShared, runStreamSVar)
62import qualified Streamly.Streams.StreamK as K
64#include "Instances.hs"
66-------------------------------------------------------------------------------
68-------------------------------------------------------------------------------
70{-# INLINE workLoopLIFO #-}
78workLoopLIFO q st sv winfo = run
85 Nothing -> liftIO $ sendStop sv winfo
86 Just m -> runStreamSVar sv m st run single yieldk
89 res <- liftIO $ sendYield sv winfo (ChildYield a)
90 if res then run else liftIO $ sendStop sv winfo
93 res <- liftIO $ sendYield sv winfo (ChildYield a)
95 then runStreamSVar sv r st run single yieldk
100 dequeue = liftIO $ atomicModifyIORefCAS q $ \case
102 x : xs -> (xs, Just x)
104-- We duplicate workLoop for yield limit and no limit cases because it has
105-- around 40% performance overhead in the worst case.
107-- XXX we can pass yinfo directly as an argument here so that we do not have to
108-- make a check every time.
109{-# INLINE workLoopLIFOLimited #-}
112 => IORef [Stream m a]
117workLoopLIFOLimited q st sv winfo = run
124 Nothing -> liftIO $ sendStop sv winfo
126 -- XXX This is just a best effort minimization of concurrency
127 -- to the yield limit. If the stream is made of concurrent
128 -- streams we do not reserve the yield limit in the constituent
129 -- streams before executing the action. This can be done
130 -- though, by sharing the yield limit ref with downstream
131 -- actions via state passing. Just a todo.
132 yieldLimitOk <- liftIO $ decrementYieldLimit sv
135 let stop = liftIO (incrementYieldLimit sv) >> run
136 runStreamSVar sv m st stop single yieldk
137 -- Avoid any side effects, undo the yield limit decrement if we
138 -- never yielded anything.
141 incrementYieldLimit sv
145 res <- liftIO $ sendYield sv winfo (ChildYield a)
146 if res then run else liftIO $ sendStop sv winfo
148 -- XXX can we pass on the yield limit downstream to limit the concurrency
149 -- of constituent streams.
151 res <- liftIO $ sendYield sv winfo (ChildYield a)
152 yieldLimitOk <- liftIO $ decrementYieldLimit sv
153 let stop = liftIO (incrementYieldLimit sv) >> run
154 if res && yieldLimitOk
155 then runStreamSVar sv r st stop single yieldk
157 incrementYieldLimit sv
161 dequeue = liftIO $ atomicModifyIORefCAS q $ \case
163 x : xs -> (xs, Just x)
165-------------------------------------------------------------------------------
167-------------------------------------------------------------------------------
169-- XXX we can remove sv as it is derivable from st
171{-# INLINE workLoopFIFO #-}
174 => LinkedQueue (Stream m a)
179workLoopFIFO q st sv winfo = run
184 work <- liftIO $ tryPopR q
186 Nothing -> liftIO $ sendStop sv winfo
187 Just m -> runStreamSVar sv m st run single yieldk
190 res <- liftIO $ sendYield sv winfo (ChildYield a)
191 if res then run else liftIO $ sendStop sv winfo
194 res <- liftIO $ sendYield sv winfo (ChildYield a)
196 then runStreamSVar sv r st run single yieldk
201{-# INLINE workLoopFIFOLimited #-}
204 => LinkedQueue (Stream m a)
209workLoopFIFOLimited q st sv winfo = run
214 work <- liftIO $ tryPopR q
216 Nothing -> liftIO $ sendStop sv winfo
218 yieldLimitOk <- liftIO $ decrementYieldLimit sv
221 let stop = liftIO (incrementYieldLimit sv) >> run
222 runStreamSVar sv m st stop single yieldk
225 incrementYieldLimit sv
229 res <- liftIO $ sendYield sv winfo (ChildYield a)
230 if res then run else liftIO $ sendStop sv winfo
233 res <- liftIO $ sendYield sv winfo (ChildYield a)
234 yieldLimitOk <- liftIO $ decrementYieldLimit sv
235 let stop = liftIO (incrementYieldLimit sv) >> run
236 if res && yieldLimitOk
237 then runStreamSVar sv r st stop single yieldk
239 incrementYieldLimit sv
243-------------------------------------------------------------------------------
245-- This code belongs in SVar.hs but is kept here for perf reasons
246-------------------------------------------------------------------------------
248-- XXX we have this function in this file because passing runStreamLIFO as a
249-- function argument to this function results in a perf degradation of more
250-- than 10%. Need to investigate what the root cause is.
251-- Interestingly, the same thing does not make any difference for Ahead.
252getLifoSVar :: forall m a. MonadAsync m
253 => State Stream m a -> RunInIO m -> IO (SVar Stream m a)
254getLifoSVar st mrun = do
255 outQ <- newIORef ([], 0)
256 outQMv <- newEmptyMVar
258 wfw <- newIORef False
259 running <- newIORef S.empty
261 yl <- case getYieldLimit st of
262 Nothing -> return Nothing
263 Just x -> Just <$> newIORef x
264 rateInfo <- getYieldRateInfo st
266 stats <- newSVarStats
269 let isWorkFinished _ = null <$> readIORef q
271 let isWorkFinishedLimited sv = do
273 case remainingWork sv of
277 Nothing -> return False
278 qEmpty <- null <$> readIORef q
279 return $ qEmpty || yieldsDone
281 let getSVar :: SVar Stream m a
282 -> (SVar Stream m a -> m [ChildEvent a])
283 -> (SVar Stream m a -> m Bool)
284 -> (SVar Stream m a -> IO Bool)
285 -> (IORef [Stream m a]
291 getSVar sv readOutput postProc workDone wloop = SVar
294 , maxBufferLimit = getMaxBuffer st
295 , maxWorkerLimit = getMaxThreads st
296 , yieldRateInfo = rateInfo
297 , outputDoorBell = outQMv
298 , readOutputQ = readOutput sv
299 , postProcess = postProc sv
300 , workerThreads = running
301 , workLoop = wloop q st{streamVar = Just sv} sv
302 , enqueue = enqueueLIFO sv q
303 , isWorkDone = workDone sv
304 , isQueueDone = workDone sv
306 , svarStyle = AsyncVar
308 , workerCount = active
309 , accountThread = delThread sv
310 , workerStopMVar = undefined
312 , svarInspectMode = getInspectMode st
314 , aheadWorkQueue = undefined
315 , outputHeap = undefined
320 case getStreamRate st of
322 case getYieldLimit st of
323 Nothing -> getSVar sv readOutputQBounded
327 Just _ -> getSVar sv readOutputQBounded
329 isWorkFinishedLimited
332 case getYieldLimit st of
333 Nothing -> getSVar sv readOutputQPaced
337 Just _ -> getSVar sv readOutputQPaced
339 isWorkFinishedLimited
343getFifoSVar :: forall m a. MonadAsync m
344 => State Stream m a -> RunInIO m -> IO (SVar Stream m a)
345getFifoSVar st mrun = do
346 outQ <- newIORef ([], 0)
347 outQMv <- newEmptyMVar
349 wfw <- newIORef False
350 running <- newIORef S.empty
352 yl <- case getYieldLimit st of
353 Nothing -> return Nothing
354 Just x -> Just <$> newIORef x
355 rateInfo <- getYieldRateInfo st
357 stats <- newSVarStats
360 let isWorkFinished _ = nullQ q
361 let isWorkFinishedLimited sv = do
363 case remainingWork sv of
367 Nothing -> return False
369 return $ qEmpty || yieldsDone
371 let getSVar :: SVar Stream m a
372 -> (SVar Stream m a -> m [ChildEvent a])
373 -> (SVar Stream m a -> m Bool)
374 -> (SVar Stream m a -> IO Bool)
375 -> (LinkedQueue (Stream m a)
381 getSVar sv readOutput postProc workDone wloop = SVar
384 , maxBufferLimit = getMaxBuffer st
385 , maxWorkerLimit = getMaxThreads st
386 , yieldRateInfo = rateInfo
387 , outputDoorBell = outQMv
388 , readOutputQ = readOutput sv
389 , postProcess = postProc sv
390 , workerThreads = running
391 , workLoop = wloop q st{streamVar = Just sv} sv
392 , enqueue = enqueueFIFO sv q
393 , isWorkDone = workDone sv
394 , isQueueDone = workDone sv
396 , svarStyle = WAsyncVar
398 , workerCount = active
399 , accountThread = delThread sv
400 , workerStopMVar = undefined
402 , svarInspectMode = getInspectMode st
404 , aheadWorkQueue = undefined
405 , outputHeap = undefined
410 case getStreamRate st of
412 case getYieldLimit st of
413 Nothing -> getSVar sv readOutputQBounded
417 Just _ -> getSVar sv readOutputQBounded
419 isWorkFinishedLimited
422 case getYieldLimit st of
423 Nothing -> getSVar sv readOutputQPaced
427 Just _ -> getSVar sv readOutputQPaced
429 isWorkFinishedLimited
433{-# INLINABLE newAsyncVar #-}
434newAsyncVar :: MonadAsync m
435 => State Stream m a -> Stream m a -> m (SVar Stream m a)
437 mrun <- captureMonadState
438 sv <- liftIO $ getLifoSVar st mrun
441-- XXX Get rid of this?
442-- | Make a stream asynchronous, triggers the computation and returns a stream
443-- in the underlying monad representing the output generated by the original
444-- computation. The returned action is exhaustible and must be drained once. If
445-- not drained fully we may have a thread blocked forever and once exhausted it
446-- will always return 'empty'.
449{-# INLINABLE mkAsync #-}
450mkAsync :: (IsStream t, MonadAsync m) => t m a -> m (t m a)
451mkAsync m = fmap fromSVar (newAsyncVar defState (toStream m))
453{-# INLINABLE mkAsync' #-}
454mkAsync' :: (IsStream t, MonadAsync m) => State Stream m a -> t m a -> m (t m a)
455mkAsync' st m = fmap fromSVar (newAsyncVar st (toStream m))
457-- | Create a new SVar and enqueue one stream computation on it.
458{-# INLINABLE newWAsyncVar #-}
459newWAsyncVar :: MonadAsync m
460 => State Stream m a -> Stream m a -> m (SVar Stream m a)
461newWAsyncVar st m = do
462 mrun <- captureMonadState
463 sv <- liftIO $ getFifoSVar st mrun
466------------------------------------------------------------------------------
467-- Running streams concurrently
468------------------------------------------------------------------------------
470-- Concurrency rate control.
472-- Our objective is to create more threads on demand if the consumer is running
473-- faster than us. As soon as we encounter a concurrent composition we create a
474-- push pull pair of threads. We use an SVar for communication between the
475-- consumer, pulling from the SVar and the producer who is pushing to the SVar.
476-- The producer creates more threads if the SVar drains and becomes empty, that
477-- is the consumer is running faster.
479-- XXX Note 1: This mechanism can be problematic if the initial production
480-- latency is high, we may end up creating too many threads. So we need some
481-- way to monitor and use the latency as well. Having a limit on the dispatches
482-- (programmer controlled) may also help.
484-- TBD Note 2: We may want to run computations at the lower level of the
485-- composition tree serially even when they are composed using a parallel
486-- combinator. We can use 'serial' in place of 'async' and 'wSerial' in
487-- place of 'wAsync'. If we find that an SVar immediately above a computation
488-- gets drained empty we can switch to parallelizing the computation. For that
489-- we can use a state flag to fork the rest of the computation at any point of
490-- time inside the Monad bind operation if the consumer is running at a faster
493-- TBD Note 3: the binary operation ('parallel') composition allows us to
494-- dispatch a chunkSize of only 1. If we have to dispatch in arbitrary
495-- chunksizes we will need to compose the parallel actions using a data
496-- constructor (A Free container) instead so that we can divide it in chunks of
497-- arbitrary size before dispatching. If the stream is composed of
498-- hierarchically composed grains of different sizes then we can always switch
499-- to a desired granularity depending on the consumer speed.
501-- TBD Note 4: for pure work (when we are not in the IO monad) we can divide it
502-- into just the number of CPUs.
504-- | Join two computations on the currently running 'SVar' queue for concurrent
505-- execution. When we are using parallel composition, an SVar is passed around
506-- as a state variable. We try to schedule a new parallel computation on the
507-- SVar passed to us. The first time, when no SVar exists, a new SVar is
508-- created. Subsequently, 'joinStreamVarAsync' may get called when a computation
509-- already scheduled on the SVar is further evaluated. For example, when (a
510-- `parallel` b) is evaluated it calls a 'joinStreamVarAsync' to put 'a' and 'b' on
511-- the current scheduler queue.
513-- The 'SVarStyle' required by the current composition context is passed as one
514-- of the parameters. If the scheduling and composition style of the new
515-- computation being scheduled is different than the style of the current SVar,
516-- then we create a new SVar and schedule it on that. The newly created SVar
517-- joins as one of the computations on the current SVar queue.
519-- Cases when we need to switch to a new SVar:
521-- * (x `parallel` y) `parallel` (t `parallel` u) -- all of them get scheduled on the same SVar
522-- * (x `parallel` y) `parallel` (t `async` u) -- @t@ and @u@ get scheduled on a new child SVar
523-- because of the scheduling policy change.
524-- * if we 'adapt' a stream of type 'async' to a stream of type
525-- 'Parallel', we create a new SVar at the transitioning bind.
526-- * When the stream is switching from disjunctive composition to conjunctive
527-- composition and vice-versa we create a new SVar to isolate the scheduling
530forkSVarAsync :: MonadAsync m
531 => SVarStyle -> Stream m a -> Stream m a -> Stream m a
532forkSVarAsync style m1 m2 = mkStream $ \st stp sng yld -> do
534 AsyncVar -> newAsyncVar st (concurrently m1 m2)
535 WAsyncVar -> newWAsyncVar st (concurrently m1 m2)
536 _ -> error "illegal svar type"
537 unStream (fromSVar sv) st stp sng yld
539 concurrently ma mb = mkStream $ \st stp sng yld -> do
540 liftIO $ enqueue (fromJust $ streamVar st) mb
541 unStreamShared ma st stp sng yld
543{-# INLINE joinStreamVarAsync #-}
544joinStreamVarAsync :: MonadAsync m
545 => SVarStyle -> Stream m a -> Stream m a -> Stream m a
546joinStreamVarAsync style m1 m2 = mkStream $ \st stp sng yld ->
548 Just sv | svarStyle sv == style ->
549 liftIO (enqueue sv m2) >> unStreamShared m1 st stp sng yld
550 _ -> unStreamShared (forkSVarAsync style m1 m2) st stp sng yld
552------------------------------------------------------------------------------
553-- Semigroup and Monoid style compositions for parallel actions
554------------------------------------------------------------------------------
557asyncS :: MonadAsync m => Stream m a -> Stream m a -> Stream m a
558asyncS = joinStreamVarAsync AsyncVar
560-- | Polymorphic version of the 'Semigroup' operation '<>' of 'AsyncT'.
561-- Merges two streams possibly concurrently, preferring the
562-- elements from the left one when available.
566async :: (IsStream t, MonadAsync m) => t m a -> t m a -> t m a
567async m1 m2 = fromStream $ mkStream $ \st stp sng yld ->
568 unStreamShared (joinStreamVarAsync AsyncVar (toStream m1) (toStream m2))
574{-# DEPRECATED (<|) "Please use 'async' instead." #-}
576(<|) :: (IsStream t, MonadAsync m) => t m a -> t m a -> t m a
579-- | XXX we can implement it more efficienty by directly implementing instead
580-- of combining streams using async.
581{-# INLINE consMAsync #-}
582consMAsync :: MonadAsync m => m a -> Stream m a -> Stream m a
583consMAsync m r = K.yieldM m `asyncS` r
585------------------------------------------------------------------------------
587------------------------------------------------------------------------------
589-- | Deep async composition or async composition with depth first traversal. In
590-- a left to right 'Semigroup' composition it tries to yield elements from the
591-- left stream as long as it can, but it can run the right stream in parallel
592-- if it needs to, based on demand. The right stream can be run if the left
593-- stream blocks on IO or cannot produce elements fast enough for the consumer.
596-- main = ('toList' . 'asyncly' $ (fromFoldable [1,2]) \<> (fromFoldable [3,4])) >>= print
602-- Any exceptions generated by a constituent stream are propagated to the
603-- output stream. The output and exceptions from a single stream are guaranteed
604-- to arrive in the same order in the resulting stream as they were generated
605-- in the input stream. However, the relative ordering of elements from
606-- different streams in the resulting stream can vary depending on scheduling
607-- and generation delays.
609-- Similarly, the monad instance of 'AsyncT' /may/ run each iteration
610-- concurrently based on demand. More concurrent iterations are started only
611-- if the previous iterations are not able to produce enough output for the
616-- import qualified "Streamly.Prelude" as S
617-- import Control.Concurrent
619-- main = 'runStream' . 'asyncly' $ do
620-- n <- return 3 \<\> return 2 \<\> return 1
622-- threadDelay (n * 1000000)
623-- myThreadId >>= \\tid -> putStrLn (show tid ++ ": Delay " ++ show n)
626-- ThreadId 40: Delay 1
627-- ThreadId 39: Delay 2
628-- ThreadId 38: Delay 3
631-- All iterations may run in the same thread if they do not block.
633-- Note that async composition with depth first traversal can be used to
634-- combine infinite number of streams as it explores only a bounded number of
638newtype AsyncT m a = AsyncT {getAsyncT :: Stream m a}
639 deriving (MonadTrans)
641-- | A demand driven left biased parallely composing IO stream of elements of
642-- type @a@. See 'AsyncT' documentation for more details.
645type Async = AsyncT IO
647-- | Fix the type of a polymorphic stream as 'AsyncT'.
650asyncly :: IsStream t => AsyncT m a -> t m a
653instance IsStream AsyncT where
658 {-# SPECIALIZE consM :: IO a -> AsyncT IO a -> AsyncT IO a #-}
659 consM m r = fromStream $ consMAsync m (toStream r)
662 {-# SPECIALIZE (|:) :: IO a -> AsyncT IO a -> AsyncT IO a #-}
665------------------------------------------------------------------------------
667------------------------------------------------------------------------------
669instance MonadAsync m => Semigroup (AsyncT m a) where
672------------------------------------------------------------------------------
674------------------------------------------------------------------------------
676instance MonadAsync m => Monoid (AsyncT m a) where
680------------------------------------------------------------------------------
682------------------------------------------------------------------------------
684instance MonadAsync m => Monad (AsyncT m) where
686 (AsyncT m) >>= f = AsyncT $ K.bindWith asyncS m (getAsyncT . f)
688------------------------------------------------------------------------------
690------------------------------------------------------------------------------
692MONAD_APPLICATIVE_INSTANCE(AsyncT,MONADPARALLEL)
693MONAD_COMMON_INSTANCES(AsyncT, MONADPARALLEL)
695------------------------------------------------------------------------------
697------------------------------------------------------------------------------
699{-# INLINE wAsyncS #-}
700wAsyncS :: MonadAsync m => Stream m a -> Stream m a -> Stream m a
701wAsyncS = joinStreamVarAsync WAsyncVar
703-- | XXX we can implement it more efficienty by directly implementing instead
704-- of combining streams using wAsync.
705{-# INLINE consMWAsync #-}
706consMWAsync :: MonadAsync m => m a -> Stream m a -> Stream m a
707consMWAsync m r = K.yieldM m `wAsyncS` r
709-- | Polymorphic version of the 'Semigroup' operation '<>' of 'WAsyncT'.
710-- Merges two streams concurrently choosing elements from both fairly.
714wAsync :: (IsStream t, MonadAsync m) => t m a -> t m a -> t m a
715wAsync m1 m2 = fromStream $ mkStream $ \st stp sng yld ->
716 unStreamShared (wAsyncS (toStream m1) (toStream m2)) st stp sng yld
718-- | Wide async composition or async composition with breadth first traversal.
719-- The Semigroup instance of 'WAsyncT' concurrently /traverses/ the composed
720-- streams using a depth first travesal or in a round robin fashion, yielding
721-- elements from both streams alternately.
724-- main = ('toList' . 'wAsyncly' $ (fromFoldable [1,2]) \<> (fromFoldable [3,4])) >>= print
730-- Any exceptions generated by a constituent stream are propagated to the
731-- output stream. The output and exceptions from a single stream are guaranteed
732-- to arrive in the same order in the resulting stream as they were generated
733-- in the input stream. However, the relative ordering of elements from
734-- different streams in the resulting stream can vary depending on scheduling
735-- and generation delays.
737-- Similarly, the 'Monad' instance of 'WAsyncT' runs /all/ iterations fairly
738-- concurrently using a round robin scheduling.
742-- import qualified "Streamly.Prelude" as S
743-- import Control.Concurrent
745-- main = 'runStream' . 'wAsyncly' $ do
746-- n <- return 3 \<\> return 2 \<\> return 1
748-- threadDelay (n * 1000000)
749-- myThreadId >>= \\tid -> putStrLn (show tid ++ ": Delay " ++ show n)
752-- ThreadId 40: Delay 1
753-- ThreadId 39: Delay 2
754-- ThreadId 38: Delay 3
757-- Unlike 'AsyncT' all iterations are guaranteed to run fairly
758-- concurrently, unconditionally.
760-- Note that async composition with breadth first traversal can only combine a
761-- finite number of streams as it needs to retain state for each unfinished
765newtype WAsyncT m a = WAsyncT {getWAsyncT :: Stream m a}
766 deriving (MonadTrans)
768-- | A round robin parallely composing IO stream of elements of type @a@.
769-- See 'WAsyncT' documentation for more details.
772type WAsync = WAsyncT IO
774-- | Fix the type of a polymorphic stream as 'WAsyncT'.
777wAsyncly :: IsStream t => WAsyncT m a -> t m a
780instance IsStream WAsyncT where
781 toStream = getWAsyncT
785 {-# SPECIALIZE consM :: IO a -> WAsyncT IO a -> WAsyncT IO a #-}
786 consM m r = fromStream $ consMWAsync m (toStream r)
789 {-# SPECIALIZE (|:) :: IO a -> WAsyncT IO a -> WAsyncT IO a #-}
792------------------------------------------------------------------------------
794------------------------------------------------------------------------------
796instance MonadAsync m => Semigroup (WAsyncT m a) where
799------------------------------------------------------------------------------
801------------------------------------------------------------------------------
803instance MonadAsync m => Monoid (WAsyncT m a) where
807------------------------------------------------------------------------------
809------------------------------------------------------------------------------
811instance MonadAsync m => Monad (WAsyncT m) where
814 WAsyncT $ K.bindWith wAsyncS m (getWAsyncT . f)
816------------------------------------------------------------------------------
818------------------------------------------------------------------------------
820MONAD_APPLICATIVE_INSTANCE(WAsyncT,MONADPARALLEL)
821MONAD_COMMON_INSTANCES(WAsyncT, MONADPARALLEL)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list