首页 > 代码库 > 线程、内存、锁定和阻塞(Threads, Memory, Locking, and Blocking)

线程、内存、锁定和阻塞(Threads, Memory, Locking, and Blocking)

线程、内存、锁定和阻塞(Threads, Memory, Locking, and Blocking)

 

如果你真的想进行并行编程的话,花点时间理解线程和内存的概念是完全值得的。在这一节,我们将学习如何显式地创建线程,并控制对共享资源,比如内存的访问。我的忠告是,应该避免你这样显式创建和管理线程,然而,在使用其他的并行编程方法时,理解底层的线程概念是需要的。

程序运行时,操作系统会创建一个进程(process)来运行,这个进程代表分配给这个程序的资源,最常见的是分配给它的内存。进程可以有一个或多个线程,负责运行程序的指令,共享进行的内存。在 .NEt 中,程序是以运行这个程序代码的线程开始的,在 F# 中,创建一个额外的线程,使用System.Threading.Thread 类。线程类的构造函数有一个代理参数,表示这个线程将要开始运行的函数;线程类构造以后,并不自动运行,必须调用它的 Start 方法。下面的示例演示了如何创建并启动一个新线程:

 

open System.Threading

 

let main() =

  //create a new thread passing it a lambda function

  letthread = new Thread(fun () ->

    //print a message on the newly created thread

    printfn"Created thread: %i" Thread.CurrentThread.ManagedThreadId)

  //start the new thread

  thread.Start()

  //print an message on the original thread

  printfn"Orginal thread: %i" Thread.CurrentThread.ManagedThreadId

  //wait of the created thread to exit

  thread.Join()

 

do main()

 

前面程序的运行结果你这样:

 

Orginal thread: 1

Created thread: 3

 

在这个示例中,你应该看两个重要的内容:第一,原始线程打印的消息在第二个线程打印之前,这是因为调用线程的 Start 方法并不立即启动这个线程,相反,把新线程的运行和操作系统选择何时运行它列入任务计划。通常,这个延迟很短,但是,原始线程会继续运行,因此,原始线程在新线程开始运行之前就运行了几条指令是完全可能的;第二,注意一下如何使用线程的 Jion 函数等待线程的退出。如果不这样做,最大的可能是原始线程可能已经运行结束,第二个线程才有机会启动。原始线程等待创建线程来完成工作,被称为阻塞。线程被阻塞有多种原因,例如,它可等待锁(lock),可能是等待输入输出的完成。当线程被锁定,操作系统会切换到下一个可运行的线程,这称为上下文切换(context switch)。我们将在下一节学习有关锁定的内容,在这一节,我们看一下异步编程中阻塞输入输出的操作。

任何资源被两个不同的线程同时修改,是有被破坏的风险的,这是因为线程可能在任何时候进行上下文切换,而遗留的操作可能只完成了原子操作(atomic,不应该再分)的一半,要想避免这种破坏,就要用到锁。锁,有时也称为监视器(monitor),这是一段代码,一次只能有一个线程通过。在 F# 中,我们使用 lock(锁)函数创建和控制锁,是通过锁定对象来实现的,其思想是这样的:一旦拿到锁,企图进入这段代码的任何线程都会被阻塞,直到由拿到这个锁的线程把这个锁释放为止。采用这种方式保护的代码有时也称为临界区(critical section),通过在打算保护的代码开始时调用System.Threading.Monitor.Enter,在这段代码的结束时调用 System.Threading.Monitor.Exit实现;必须保证 Monitor.Exit 被调用,否则,可能导致线程一直被锁定;锁函数是确保如果调用Monitor.Enter 之后,Monitor.Exit 总是被调用的一个好办法。这个函数有两个参数:第一个是打算锁定的对象,第二个是包含了想要保护的代码;这个函数可能把空(unit)作为参数,返回可以是任意值。

下面的示例演示了涉及锁定的微妙问题[ 需要仔细体会],完成锁定的代码需要相当长的时间,因此,示例故意写成夸大上下文切换的问题。代码背后的思想是这样的:如果两个线程同时运行,且都尝试写到控制台;其目标是把字符串"One ... Two ... Three ... " 以原子方式写到控制台,就是说,一个线程能名在下一外委会线程启动之前,应该能够完成写消息。示例有一个函数makeUnsafeThread,它创建的线程不能原子地把字符串写到控制台;第二个函数[ 不应该是线程] makeSafeThread,通过使用锁,可以原子地写到控制台:

 

open System

open System.Threading

 

// function to print to the consolecharacter by character

// this increases the chance of there beinga context switch

// between threads.

let printSlowly (s : string) =

  s.ToCharArray()

  |>Array.iter (printf "%c")

  printfn""

 

// create a thread that prints to the consolein an unsafe way

let makeUnsafeThread() =

  newThread(fun () ->

  forx in 1 .. 100 do

  printSlowly"One ... Two ... Three ... ")

 

// the object that will be used as a lock

let lockObj = new Object()

 

// create a thread that prints to theconsole in a safe way

let makeSafeThread() =

  newThread(fun () ->

  forx in 1 .. 100 do

    //use lock to ensure operation is atomic

    locklockObj (fun () ->

     printSlowly "One ... Two ... Three ... "))

 

// helper function to run the test to

let runTest (f: unit -> Thread) message=

  printfn"%s" message

  lett1 = f() in

  lett2 = f() in

  t1.Start()

  t2.Start()

  t1.Join()

  t2.Join()

 

// runs the demonstrations

let main() =

  runTest

    makeUnsafeThread

    "Runningtest without locking ..."

  runTest

    makeSafeThread

    "Runningtest with locking ..."

 

do main()

 

为了突出重点,我们把示例中使用锁的部分再重复一下,应该注意两个重要的地方:第一,通过声明lockObj 创建一个临界区;第二,把我们的代码嵌入到makeSafeThread 函数中的锁函数中。需要注意的最重要部分,是怎样、何时把想要原子化的函数,放在内部函数传递给锁函数:

 

// the object that will be used as a lock

let lockObj = new Object()

 

// create a thread that prints to theconsole in a safe way

let makeSafeThread() =

  newThread(fun () ->

  forx in 1 .. 100 do

    //use lock to ensure operation is atomic

    locklockObj (fun () ->

     printSlowly "One ... Two ... Three ... "))

 

第一部分代码每次运行结果不同,因为它取决于线程何时发生上下文切换;它还取决于处理器数量,因为如果机器在有两个或更多的处理器,同时可能有多个线程在运行,这样,消息将会更加紧密地挤在一起;而在单处理器的机器上,输出很少会挤在一起,因为,当上下文切换时,消息已经打印出去了[ 原文:On a single-processor machine, the output will be less tightlypacked together because printing a message will go wrong only when a contentswitch takes place ]。下面是第一部分代码在双处理器机器运行的示意:

 

Running test without locking ...

...

One ... Two ... Three ...

One One ... Two ... Three ...

One ... Two ... Three ...

...

 

而锁定意谓着示例的第二部分的结果根本不会不同,因此,看到的问题这样:

 

Running test with locking ...

One ... Two ... Three ...

One ... Two ... Three ...

One ... Two ... Three ...

...

 

锁定是并发的一个重要方面,所有需要在线程之间进行写入或共享的资源都应该锁定。资源通常是可变的,可能是文件,甚至是控制台,正如示例所演示的。虽然锁能够解决并发性,但它也有产生问题,因为会产生死锁(deadlock)。当两个或更多的线程锁定了其他线程需要的资源,而谁也不会先释放,于是就发生了死锁。解决并发性的最简单办法是避免共享可能发生写入的资源。在本章的后面,我们将会看到创建的并行程序并不显式依赖锁。

 

注意

本书介绍的线程内容非常有限,如果想进行并行编程还需要更多地了解线程。在 MSDN 上的托管线程处理基本知识是一个好去处:http://msdn.microsoft.com/zh-cn/library/hyz69czz.aspx[ 已经改成中文站点了。];另外,也可以在http://www.albahari.com/threading/找到有用的教程。