Programowanie współbieżne i rozproszone/PWR Ćwiczenia shm

From Studia Informatyczne

Spis treści

Tematyka zajęć

Segmenty pamięci dzielonej

Literatura

Scenariusz

Wstęp

Segmenty pamięci dzielonej to trzeci, ostatni już mechanizm z pakietu IPC. Jak zwykle do segmentów pamięci dzielonej stosują się wszystkie uwagi dotyczące mechanizmów IPC, omówione przy okazji kolejek komunikatów. Dotychczas wszystkie programy, które pisaliśmy w środowisku Unix składały się z procesów, z których każdy korzystał z własnych zmiennych. Zmienne współdzielone, dostępne dla wielu procesów nie występowały, a synchronizacja odbywała się poprzez wymianę komunikatów bądź to za pomocą łączy bądź kolejek komunikatów. Pewne odstępstwo od tej zasady nastąpiło z chwilą wprowadzenia semaforów, ale ponieważ to system dba o zarządzanie nimi i przydzielenie im miejsca w pamięci, więc programista nie musiał się tym zajmować. Dziś chcemy korzystać ze wspólnej pamięci w postaci zmiennych dostępnych dla wielu procesów.

Choć procesy uniksowe uruchamiane na tym samym komputerze wykonują się we wspólnej pamięci, to jednak z logicznego punktu widzenia przestrzenie adresowe różnych procesów są rozłączne. Aby umożliwić korzystanie na przykład ze wspólnych zmiennych wprowadzono, początkowo do Uniksa Systemu V, a później już do wszystkich wersji systemu, mechanizm segmentow pamieci dzielonej.

Segment pamięci dzielonej jest fragmentem pamięci, który może być podpinany do przestrzeni adresowych różnych procesów. Zarządzaniem segmentami pamięci dzielonej zajmuje się system, ale to programista jest odpowiedzialny za utworzenie segmentu o potrzebnej mu wielkości oraz jego podpięcie pod pewien adres przestrzeni adresowej (najczęściej wybrany przez system operacyjny). Jeśli ten sam segment zostanie przyłączony do przestrzeni adresowych różnych procesów, to wszelkie dane (a więc zmienne) w nim przechowywanie stają się widoczne i dostępne dla tych procesów. Oczywiście dostęp do nich wymaga wówczas synchronizacji, ale mamy już do tego odpowiednie mechanizmy --- na przykład semafory.

Operacje zapisu i odczytu z segmentu pamięci wirtualnej do zwykłe operacje dostępu do pamięci. Dzięki temu są one realizowane szybko i wydajnie, znacznie szybciej niż na przykład wysłanie komunikatu za pomocą kolejki komunikatów, które wymaga kopiowania komunikatu między przestrzenią adresową nadawcy, jądra oraz odbiorcy. Tutaj występuje zapis po stronie nadawcy i odczyt po stronie odbiorcy.

Ponieważ segment pamięci jest zasobem IPC, trzeba go najpierw utworzyć lub uzyskać dostęp do segmentu już istniejącego. Identyfikacja segmentów odbywa się jak zwykle za pomocą ustalonego klucza, a następnie identyfikatora przekazywanego przez funkcję get, która w przypadku segmentów pamięci dzielonej nazywa się shmget. Ale to jeszcze nie koniec. Aby proces mógł coś zapisać do segmentu lub z niego odczytać, segment musi zostać podpięty do przestrzeni adresowej. Podobnie, gdy segment staje się niepotrzebny w danym procesie należy go odłączyc od przestrzeni adresowej. Dopóki jednak segment jest przyłączony do jakiekolwiek procesu, dane w nim zapisywane są utrzymywane. Segment, który jest już niepotrzebny w żadnym procesie należy usunąć, jak każdy zasób IPC.

Tak wygląda typowy scenariusz pracy z segmentami pamięci dzielonej. Do jego realizacji służą następujące funkcje systemowe:

  • shmget --- tworząca segment pamięci dzielonej
  • shmat --- przyłączająca segmentu pamięci dzielonej do przestrzeni adresowej procesu
  • shmdt --- odlączająca segmentu pamięci dzielonej od przestrzeni adresowej procesu
  • shmctl --- realizująca operacje sterujące na segmencie pamięci dzielonej

Program w C wykorzystujący mechanizm pamięci dzielonej musi wykorzystać następujące pliki nagłówkowe:

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

Tworzenie (uzyskanie dostępu do) segmentu pamięci dzielonej

Do utworzenia segmentu pamięci dzielonej służy funkcja systemowa shmget:

int shmget (key_t klucz, int rozmiar, int flagi)
  • Pierwszym parametrem tej funkcji jest jak zwykle klucz zasobu
  • Trzecim parametrem są opcje, które jak zwykle określają prawa dostępu do pamięci oraz ewentualne opcje IPC_CREAT, IPC_EXCL lub IPC_PRIVATE
  • Wynikiem funkcji shmget jest identyfikator segmentu pamięci dzielonej lub -1 w przypadku błędu

Parametrem specyficznym dla segmentów pamięci dzielonej jest rozmiar, który określa rozmiar tworzonego segmentu pamięci dzielonej. Ma on znaczenie jedynie wtedy, gdy segment jest faktycznie tworzony. Jeśli funkcja powoduje jedynie uzyskanie dostępu do już istniejącego segmentu, to parametr ten jest ignorowany. Tak naprawdę, utworzony segment będzie miał będzie składał się z pewnej (odpowiednio dużej) liczby stron, więc jego rozmiar jest zawsze wielokrotnością rozmiaru strony.

Przyłączanie segmentu pamięci dzielonej

Do przyłączenia istniejącego segmentu pamięci dzielonej do przestrzeni adresowej procesu służy funkcja systemowa shmat:

void *shmat (int ident, const void *adres, int flagi)
  • Pierwszy parametr jest identyfikatorem segmentu pamięci dzielonej.
  • Drugi parametr jest to adres wirtualny, pod który zostanie przyłączony segment. Wartość tego parametru może być:
    • zerowa, wtedy system sam dobiera odpowiednio adres. Taka postać operacji shmat jest zdecydowanie zalecana, a w niektórych wariantach Uniksa jest nawet jedyną dopuszczalną!
    • niezerowa, wtedy system próbuje przyłączyć segment pod wskazany adres. System może, w zależności od opcji podanych na trzecim argumencie, dokonać korekty tego adresu, ale nie będziemy się tym zajmować, stosując zawsze zalecaną wersję z wartością parametru adres równą zero.
  • Trzeci parametr to opcje, które mają wpływ na sposób przyłączenia. Jest wśród nich między innymi opcja SHM_RDONLY, wymuszająca przyłączenie w trybie jedynie do odczytu

Wynikiem funkcji systemowej jest adres, pod który przyłączono segment lub wartość (void *) -1 w przypadku błędu.

A oto typowy fragment programu działający na segmencie pamięci dzielonej:

int   id;
char* adr;
 
if ((id=shmget (KLUCZ, 100, 0666|IPC_CREAT)) == -1)  /* Stworzenie segementu */
 syserr ("blad");  
if ((adr=shmat(id, 0, 0)) < 0) /* Przyłączenie */
 syserr ("blad")

Później w programie można napisać na przykład:

strcpy (adr, "Hello")

co powoduje umieszczenie napisu w segmencie pamięci dzielonej.

Należy zauważyć, że ten sam segment pamięci dzielonej może zostać przyłączony wielokrotnie do tego samego procesu, w różne miejsca pamięci wirtualnej, na przykład:

int   id;
char* adr, adr2;
 
if ((id=shmget (KLUCZ, 100, 0666|IPC_CREAT)) == -1)  /* Stworzenie segementu */
 syserr ("blad");  
if ((adr=shmat(id, 0, 0)) < 0) /* Przyłączenie */
 syserr ("blad")   
if ((adr2=shmat(id, 0, 0)) < 0) /* Przyłączenie */
 syserr ("blad")

Później w programie można napisać na przykład:

strcpy (adr, "Hello");
  printf ("%s\n", adr2);

uzyskując na wyjściu napis "Hello".

Odłączanie segmentu pamięci dzielonej

Gdy proces przestaje korzystać z przyłączonego segmentu należy go odłączyć od przestrzeni adresowej za pomocą funkcji systemowej shmdt:

int shmdt (const void *adres)

W odróżnieniu od pozostałych funkcji działających na zasobach IPC (z wyjątkiem rodziny funkcji get), funkcja ta nie bierze jako argumentu identyfikatora zasoba. Powód jest prosty: jeśli ten sam segment jest przyłączony w różne miejsca przestrzeni adresowej tego procesu, to identyfikator nie określa jednoznacznie, który segment chcemy odłączyć. Jednoznaczną informacje dostarcza adres. W przypadku błędu wynikiem funkcji jest -1 w przeciwnym razie jest to wartość 0.

Warto zauważyć, że po odłączeniu segmentu pamięci dzielonej od procesu dane w segmencie pozostają niezmienione, nawet wówczas, gdy nie jest on już przyłączony do żadnego procesu. Dzięki temu dane pozostawione w segmencie pamięci dzielonej przez pewien proces mogą zostać odczytane przez proces, który działa w zupełnie innym czasie niż proces nadawcy.

Usuwanie segmentu pamięci dzielonej

Do wykonywania czynności zarządzających segmentem pamięci dzielonej służy funkcja systemowa shmctl. Szczegółowe informacje o nie znajdują się na stronach podręcznika man. Dla nas istotna jest tylko jedna postać:

int shmctl (int ident, IPC_RMID, 0)

powodująca usunięcie segmentu pamięci dzielonej. Usuwanie to nie jest jednak natychmiastowe. Tak naprawdę segment jest usuwany fizycznie dopiero wówczas, gdy nie jest przyłączony do żadnego procesu (z tego powodu istotne jest odłączanie niepotrzebnych już segmentów). Warto zaznaczyć, że funkcja systemowa exit odłącza wszystkie przyłączone do procesu segmenty pamięci dzielonej. Podobnie zachowuje się funkcja systemowa exec, gdyż pamięć wirtualna jest na skutek jej wykonania zupełnie podmieniana.

Przykład

A oto przykładowy program manipulujący segmentami pamięci dzielonej. Daje on możliwość operowania na segmentach z poziomu interpretera poleceń:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <errno.h>
#include "err.h"
 
#define BUFSIZE 100
 
int   shmid;
char* shmaddr;
 
void usage(void)
{
  fprintf(stderr, "\nMenu: (g)et\n");
  fprintf(stderr, "      (a)ttach\n");
  fprintf(stderr, "      (r)ead\n");
  fprintf(stderr, "      (w)rite\n");
  fprintf(stderr, "      (d)etach\n");
  fprintf(stderr, "      remo(v)\n");
  fprintf(stderr, "      (i)pcs\n");
  fprintf(stderr, "      (q)uit\n");
}
 
void shm_get()
{
  char buf[BUFSIZE];
  key_t key;
 
  printf("Key: ");
  fgets(buf, sizeof(buf) - 1, stdin);
  key = atoi(buf);
 
  if( (shmid = shmget(key, BUFSIZE, 0666 | IPC_CREAT)) < 0)
    syserr("shmget error");
  printf("Shared memory ID = %d\n", shmid);
}
 
void shm_attach()
{
  if( (shmaddr = shmat(shmid, 0, 0)) < 0)
    syserr("shmat error");
  printf("Shared memory attached\n");
}
 
void shm_detach()
{
  if( shmdt(shmaddr) < 0)
    syserr("shmdt error");
  printf("Shared memory detached\n");
}
 
void shm_remove()
{
  if( shmctl(shmid, IPC_RMID, 0) < 0)
    syserr("shmctl error");
  printf("Shared memory removed\n");
}
 
void shm_read()
{
  printf("Message: %s\n",shmaddr);
}
 
void shm_write()
{
  printf("Message: ");
  fgets(shmaddr, BUFSIZE - 1, stdin);
}
 
int main(int argc, char *argv[])
{
  char buf[BUFSIZE];
 
  usage();
  for(;;) {
    printf("->");
    fgets(buf, BUFSIZE - 1, stdin);
    switch(buf[0]) {
      case 'g':
        shm_get();
        break;
      case 'a':
        shm_attach();
        break;
      case 'r':
        shm_read();
        break;
      case 'w':
        shm_write();
        break;
      case 'd':
        shm_detach();
        break;
      case 'v':
        shm_remove();
        break;
      case 'i':
        system("ipcs");
        break;
      case 'q':
        exit(0);
      default:
        usage();
    }
  }
}

Ćwiczenia

Czytelnicy i pisarze. Algorytm klasyczny

Zapisz stosując semafory i segmenty pamięci dzielonej rozwiązanie problemu czytelników i pisarzy. Wykorzystaj rozwiązanie za pomocą semaforów klasycznych.

Przygotujmy sobie najpierw strukturę przechowującą wszystkie zmienne współdzielone.

struct ZM {
  int IluCzyta;
  int IluPisze;
  int IluCzytCzeka;
  int IluPisCzeka;
};

Inicjacja takie struktury wraz z jej przyłączeniem do przestrzeni adresowej procesu wygląda tak:

(struct ZM *) InicjujZmienne (void)
{
  int id;
  struct ZM *zm;
 
  if ((id = shmget (KLUCZ, sizeof(struct ZM), IPC_CREAT | 0666)) == -1)
    syserr ("Blad w inicjacji zmiennych");
  if ((zm = (struct ZM *) shmat (id, 0, 0)) == (struct ZM *) -1)
    syserr ("Blad w shmat");
  return zm;
}

Będziemy potrzebować też trzech semaforów: mutex, pis oraz czyt. Umieśćmy je w jednym zestawie, który zainicjujemy następująco:

#DEFINE mutex 0
#DEFINE pis 1
#DEFINE czyt 2
 
int idsem;
 
void InicjujSemafory (void)
{
  if ((idsem = semget (KLUCZSEM, 3, IPC_CREAT | IPC_EXCL | 0666)) == -1)
   {
     if (errno != EEXIST)
       syserr ("Blad w inicjacji semaforow");
     if ((idsem = semget (KLUCZSEM, 0, 0)) == -1)
       syserr ("Blad w inicjacji semaforow");
   }
  else // jestesmy pierwsi zestaw utworzony
  {
    V (mutex);
  }
}

Operacje P i V definiujemy następująco:

void P (int sem)
{
  struct sembuf sb;
 
  sb.sem_num = sem;
  sb.sem_op  = -1;
  sb.sem_flg  = 0;
  if (semop (idsem, &sb, 1) == -1)
    syserr ("Blad w P");
}
 
void V (int sem)
{
  struct sembuf sb;
 
  sb.sem_num = sem;
  sb.sem_op  = 1;
  sb.sem_flg  = 0;
  if (semop (idsem, &sb, 1) == -1)
    syserr ("Blad w V");
}

Wówczas tekst czytelnika można zapisać tak:

int main(void)
{
  pid_t pid;
  struct ZM *zm;
 
  pid = getpid();
  InicjujSemafory();
  zm=InicjujZmienne ();
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce czytac\n", pid);
      P(mutex);
      if (zm->IlePisz()>0)
        {
          (zm->IluCzytCzeka)++;
          V(mutex);
          P(czyt);
          (zm->IluCzytCzeka)--;
        }
      (zm->IluCzyta)++;
      if (zm->IluCzytCzek > 0)
        V(czyt);
      else
        V(mutex);
 
      Czytanie (pid);
 
      P(mutex);
      (zm->IluCzyta)--;
      if ((zm->IluCzyta == 0) && (zm->IluPisCzeka > 0))
        V(pis);
      else
        V(mutex);
    }
  return 0;
}

A treść pisarza wygląda tak:

int main(void)
{
  pid_t pid;
  struct ZM *zm;
 
  pid = getpid();
 
  InicjujSemafory();
  zm=InicjujZmienne ();
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce pisac\n", pid);
      P(mutex);
      if ((zm->IluPisze > 0) || (zm->IluCzyta > 0))
        {
          (zm->IluPisCzeka)++;
          V(mutex);
          P(pis);
          (zm->IluPisCzeka)--;
        }
      (zm->IluPisze)++;
      V(mutex);
 
      Pisanie (pid);
 
      P(mutex);
      (zm->IluPisze)--;
      if (zm->IluCzytCzeka > 0)
        V(czyt);
      else if (zm->IluPisCzeka > 0)
        V(pis);
      else
        V(mutex);
    }
  return 0;
}

Czytelnicy i pisarze. Bez segmentów z operacjami jednoczesnymi

To samo można uzyskać stosując jednoczesne operacje semaforowe, bez segmentów pamięci dzielonej. Nie jest to przy tym rozwiązanie polegające na "zasymulowaniu" zmiennych współdzielonych semaforami.

Oto treść czytelnika:

#include <stdio.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include "err.h"
 
#include "czytpis.h"
 
int main(void)
{
  pid_t pid;
  int idsem;
  struct sembuf sb[3];
 
  pid = getpid();
 
  if ((idsem = semget (KLUCZ, 2, IPC_CREAT | 0666)) == -1)
    syserr ("Blad w inicjacji semaforow");
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce czytac\n", pid);
 
      sb[0].sem_num = PIS;
      sb[0].sem_op  = 0;
      sb[0].sem_flg = 0;
 
      sb[1].sem_num = CZYT;
      sb[1].sem_op  = 1;
      sb[1].sem_flg = 0;
 
      if (semop (idsem, sb, 2) == -1)
        syserr ("Blad w semop");
 
 
      Czytanie (pid);
 
 
      sb[0].sem_num = CZYT;
      sb[0].sem_op  = -1;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
    }
  return 0;
}

oraz treść pisarza:

#include <stdio.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include "err.h"
 
#include "czytpis.h"
 
int main(void)
{
  pid_t pid;
  int idsem;
  struct sembuf sb[3];
 
  pid = getpid();
 
  if ((idsem = semget (KLUCZ, 2, IPC_CREAT | 0666)) == -1)
    syserr ("Blad w inicjacji semaforow");
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce pisac\n", pid);
 
      sb[0].sem_num = PIS;
      sb[0].sem_op  = 0;
      sb[0].sem_flg = 0;
 
      sb[1].sem_num = CZYT;
      sb[1].sem_op  = 0;
      sb[1].sem_flg = 0;
 
      sb[2].sem_num = PIS;
      sb[2].sem_op  = 1;
      sb[2].sem_flg = 0;
 
      if (semop (idsem, sb, 3) == -1)
        syserr ("Blad w semop");
 
      Pisanie (pid);
 
      sb[0].sem_num = PIS;
      sb[0].sem_op  = -1;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
    }
  return 0;
}

Niestety ten program prowadzi do zagłodzenia pisarzy.

Czytelnicy i pisarze. Bez segmentów z operacjami jednoczesnymi. Wersja 2

Poprawmy poprzednią implementację: Czytelnik tym razem działa tak:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include "err.h"
 
#include "czytpis.h"
 
int main(void)
{
  pid_t pid;
  int idsem;
  struct sembuf sb[3];
 
  pid = getpid();
 
  idsem = Inicjuj();
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce czytac\n", pid);
 
      sb[0].sem_num = POCZEKALNIA;
      sb[0].sem_op  = -1;
      sb[0].sem_flg = 0;
 
      sb[1].sem_num = POCZEKALNIA;
      sb[1].sem_op  = 1;
      sb[1].sem_flg = 0;
 
      sb[2].sem_num = ILU;
      sb[2].sem_op  = 1;
      sb[2].sem_flg = 0;
 
      if (semop (idsem, sb, 3) == -1)
        syserr ("Blad w semop");
 
      Czytanie (pid);
 
      sb[0].sem_num = ILU;
      sb[0].sem_op  = -1;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
    }
  return 0;
}

A pisarz tak:

#include <stdio.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <errno.h>
#include <sys/sem.h>
#include "err.h"
 
#include "czytpis.h"
 
int main(void)
{
  pid_t pid;
  int idsem;
  struct sembuf sb[3];
 
  pid = getpid();
 
  idsem = Inicjuj();
 
  for(;;)
    {
      wlasne_sprawy(pid);
 
      printf ("Proces %d --- chce pisac\n", pid);
 
      sb[0].sem_num = POCZEKALNIA;
      sb[0].sem_op  = -1;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
 
      sb[0].sem_num = ILU;
      sb[0].sem_op  = 0;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
 
      Pisanie (pid);
 
 
      sb[0].sem_num = POCZEKALNIA;
      sb[0].sem_op  = 1;
      sb[0].sem_flg = 0;
 
      if (semop (idsem, sb, 1) == -1)
        syserr ("Blad w semop");
    }
  return 0;
}

Synchronizacja procesów w parach. Stolik dwuosobowy

W systemie działa 2N procesów. Każdy proces wywołuje się z parametrem będącym liczbą całkowitą z zakresu od 1 do N, przy czym są dwa procesy z tą samą wartością parametru. Procesy korzystają z jednego zasobu. Jednocześnie z zasobu mogą korzystać procesy z tej samej pary. Aby rozpocząć korzystanie z zasobu, procesy muszą się najpierw zsynchronizować w obrębie pary (czyli oba procesy pary muszą chcieć skorzystać z zasobu i zasób musi być wolny). Procesy kończą korzystanie z zasobu niezależnie od siebie, a zasób staje się dostępny, gdy nikt już z niego nie korzysta. Zaimplementuj procesy korzystając z semaforów i segmentów pamięci dzielonej.

Model klient - serwer z wykorzystaniem pamięci dzielonej

Zapisz treść serwera i klienta z laboratorium dotyczącego kolejek komunikatów za pomocą segmentów pamięci dzielonej.