Concurrency and Synchronization: Semaphores in Action
Total Page:16
File Type:pdf, Size:1020Kb
CS-350: Fundamentals of Computing Systems Page 1 of 16 Lecture Notes Concurrency and Synchronization: Semaphores in Action In our coverage of semaphore functionality and implementation, we have seen a number of ways that semaphores could come in handy in managing concurrency amongst processes sharing resources or data structures. Broadly speaking, we have seen that semaphores could be used in the following ways: • A binary semaphore initialized to 1 could be used to ensure mutual exclusion, which allows a set of processes to correctly access shared variables such as counters. • A binary semaphore initialized to 0 could be used to force an instruction in one process to wait for the execution of an instruction in another process. This is an example of how semaphores can be used for signaling between processes. • A counting semaphore initialized to K could be used to enforce an upper limit on the number of concurrent uses of a given resource (or service or threads of execution). We will now consider a number of classical problems that are frequently encountered when building computing systems and we will see how semaphores could be used to manage the synchronization issues that arise. The Producer-Consumer Problem In many systems, it is often the case that one process (or more) will be consuming results from one other process (or more). The question is, what sort of synchronization do we need between a (set of) producer(s) and a (set of) consumer(s)? In the following, we will focus on one producer and one consumer, noting that our discussion applies to the general case in which multiple producers and/or consumers exist. First off, let us discuss how the producer and consumer might be communicating. We note that there must be a shared data structure—a buffer—in which the producer “puts” its output and from which the consumer “takes” its input. To that end, we ask the following questions: 1. Are there shared data structures that must be accessed in a mutually exclusive fashion? 2. Is it ever the case that a consumer will wait for a producer? If so, when? 3. Is it ever the case that a producer will wait for a consumer? If so, when? Regarding the first question, we note that the producer and consumer share access to the buffer data structure. Since we do not want this buffer to be left in an inconsistent state, we will require that any access to the buffer be in a mutually exclusive fashion. For that purpose, we will use a mutual exclusion semaphore—let’s call it mutex—which will be initialized to 1. Regarding the second question, we do not want a consumer to actively seek to consume results if there are none to consume. In other words, if there is nothing to consume, a consumer must © Azer Bestavros. All rights reserved. Reproduction or copying (electronic or otherwise) is expressly forbidden except for students enrolled in CS-350. CS-350: Fundamentals of Computing Systems Page 2 of 16 Lecture Notes sleep (or block). Thus, a consumer must wait for a producer “if there is nothing to consume in the buffer”. Regarding the third question, as long as a producer is able to deposit its output in the buffer (for consumers to consume) it does not have to wait. In other words, if the buffer is assumed to be infinite in size, then it will never be the case that a producer will have to wait. If the buffer is finite in size, then a producer must wait if the buffer is “full,” so as not to cause a buffer overflow (or overrun). Infinite Buffer Case: Consider the infinite buffer case. How do we ensure that a consumer will wait for a produced result? This is an instance of one process signaling another. If we use a semaphore out (initialized to 0) for that purpose, then upon producing an output, a producer will signal the out semaphore, and prior to consuming an output, a consumer must wait for the out semaphore. This suggests the code snippets shown in Figure 1. Initialization: sempahore mutex:=1; semaphore out:=0; Producer: Consumer: repeat repeat [produce v]; wait(out); wait(mutex); wait(mutex); put(v); w:=take(); signal(mutex); signal(mutex); signal(out); [consume(w)]; forever forever Figure 1 Producer-Consumer synchronization when buffer is infinite. Finite Buffer Case: With a finite buffer, we must introduce a mechanism that forces the producer to block if there is no space for it to put an item in the buffer. To that end, we use a counting semaphore space which we will use to keep track of available buffer space. Initially, this semaphore will be initialized to k, which is the initial size of the buffer. Before the producer adds an item to the buffer, it should wait on the space semaphore and after the consumer takes an item from the buffer, it should signal the space semaphore (signifying the addition of new space by virtue of the consumption of an item). This suggests the code snippets shown in Figure 2. © Azer Bestavros. All rights reserved. Reproduction or copying (electronic or otherwise) is expressly forbidden except for students enrolled in CS-350. CS-350: Fundamentals of Computing Systems Page 3 of 16 Lecture Notes Initialization: semaphore mutex:=1; semaphore out:=0; semaphore space:=k; Producer: Consumer: repeat repeat [produce v]; wait(out); wait(space); wait(mutex); wait(mutex); w:=take(); put(v); signal(mutex); signal(mutex); signal(space); signal(out); [consume(w)]; forever forever Figure 2 Producer-Consumer synchronization when buffer is finite of size k. [Exercise: Is it OK if we swap the two wait() instructions in the producer and/or consumer code? Is it OK to swap the signal() instructions in the producer and/or consumer code? Explain.] One detail which is not spelled out in the code snippets in Figure 1 and Figure 2 is the specific implementation of the buffer data structure, and thus the specification of the put() and take() functions. We note that this is straightforward. For instance, a finite buffer could be implemented with a circular buffer b[] with two pointers in and out, whereby in is a pointer to the location in b[] into which a new item may be inserted, and out is a pointer to the location in b[] from which a produced item may be consumed as illustrated in Figure 3 and as specified by the pseudo code implementation of put() and take() shown in Figure 4. Figure 3 Implementation of a circular buffer of size k with two pointers in and out. © Azer Bestavros. All rights reserved. Reproduction or copying (electronic or otherwise) is expressly forbidden except for students enrolled in CS-350. CS-350: Fundamentals of Computing Systems Page 4 of 16 Lecture Notes put(v): b[in]:=v; in:=(in+1)mod k; take(): w:=b[out]; out:=(out+1)mod k; return w; Figure 4 Implementation of the put() and take() functions on the circular buffer in Figure 3. It is important to note that our solution of the producer-consumer problem extends with little change to multiple producers and/or multiple consumers. The Readers-Writers Problem In the producer consumer problem, we dealt with a “consumable” data which is produced once and consumed once. We now turn our attention to questions of “access” to data. The simplest forms of access to data are the read and write access. A data item could be any set of bytes— for example a file in a file system, a record or a table in a database, or a page in main memory. Without loss of generality, we assume that we are interested in read/write operations on a file, where it should be understood that by “file” we mean the unit of access granularity. Before proceeding further, let us review what is meant by a read operation on a file, and what is meant by a write operation on a file. Basically, the difference between reading a file and writing a file is that reading a file does not change the content of the file, whereas writing a file does. The above distinction between reading a file and writing a file suggest that if we are keen on ensuring the consistency of the file, we should never allow two concurrent write operations on a file. In other words, we cannot allow two “writers” of the file to gain access to the file concurrently. On the other hand, there is no harm in letting two or more “readers” access the file concurrently. We now ask ourselves the following questions regarding how readers and writers should synchronize their access to a file. 1. When should a reader block? 2. When should a writer block? To answer the first question, we note that a reader should only block if a writer is currently accessing the file. To answer the second question, we note that a writer should block if either one or more readers are currently accessing the file, or if a writer is currently accessing the file. One possible solution for the readers/writers problem is to simply enforce a mutual exclusion policy on file access. Specifically, we use a single binary semaphore (call it “lock”) initialized © Azer Bestavros. All rights reserved. Reproduction or copying (electronic or otherwise) is expressly forbidden except for students enrolled in CS-350. CS-350: Fundamentals of Computing Systems Page 5 of 16 Lecture Notes to 1 and require that any reader and any writer wait on that semaphore before reading/writing the file. In addition, they must signal that semaphore upon completion of reading/writing.