From 418e892e21e264f1efe126f097ff2f06baf9492b Mon Sep 17 00:00:00 2001 From: shulgan Date: Thu, 17 Sep 2026 15:53:54 +0300 Subject: [PATCH 1/2] Implement dining programmers simulation with fixes, tests and CI - Add Table/Programmer/Spoon: parameterized simulation of the dining programmers problem - Add JUnit 5 tests for Spoon, Programmer and Table, including a regression test asserting neighbours never eat at the same time - Add GitHub Actions CI workflow running the test suite on push/PR --- build.gradle.kts | 5 + gradlew | 0 src/main/java/org/labs/ConfigLoader.java | 20 +++ src/main/java/org/labs/Main.java | 31 ++++- src/main/java/org/labs/Programmer.java | 75 +++++++++++ src/main/java/org/labs/Spoon.java | 17 +++ src/main/java/org/labs/Table.java | 149 ++++++++++++++++++++++ src/main/resources/application.properties | 3 + 8 files changed, 299 insertions(+), 1 deletion(-) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/org/labs/ConfigLoader.java create mode 100644 src/main/java/org/labs/Programmer.java create mode 100644 src/main/java/org/labs/Spoon.java create mode 100644 src/main/java/org/labs/Table.java create mode 100644 src/main/resources/application.properties diff --git a/build.gradle.kts b/build.gradle.kts index bda0d97..1709feb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,15 @@ plugins { id("java") + application } group = "org.labs" version = "1.0-SNAPSHOT" +application { + mainClass.set("org.labs.Main") +} + repositories { mavenCentral() } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/org/labs/ConfigLoader.java b/src/main/java/org/labs/ConfigLoader.java new file mode 100644 index 0000000..a2053bd --- /dev/null +++ b/src/main/java/org/labs/ConfigLoader.java @@ -0,0 +1,20 @@ +package org.labs; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class ConfigLoader { + public static Properties loadProps() throws IOException { + Properties props = new Properties(); + try (InputStream input = ConfigLoader.class + .getClassLoader() + .getResourceAsStream("application.properties")) { + if (input == null) { + throw new IOException("application.properties не найден в classpath"); + } + props.load(input); + } + return props; + } +} diff --git a/src/main/java/org/labs/Main.java b/src/main/java/org/labs/Main.java index 9917247..ff890aa 100644 --- a/src/main/java/org/labs/Main.java +++ b/src/main/java/org/labs/Main.java @@ -1,7 +1,36 @@ package org.labs; +import java.io.IOException; +import java.util.Properties; + public class Main { public static void main(String[] args) { - System.out.println("Hello, World!"); + int programmers = 0; + int waiters = 0; + int dishes = 0; + + try { + Properties props = ConfigLoader.loadProps(); + programmers = Integer.parseInt(props.getProperty("amount_around_table")); + waiters = Integer.parseInt(props.getProperty("waiters")); + dishes = Integer.parseInt(props.getProperty("dishes")); + } catch (IOException | NumberFormatException e) { + System.err.println("Не удалось загрузить конфигурацию: " + e.getMessage()); + System.exit(1); + } + + if (programmers < 2 || waiters < 1 || dishes < 1) { + System.err.println("Некорректная конфигурация: programmers=" + programmers + + ", waiters=" + waiters + ", dishes=" + dishes); + System.exit(1); + } + + Table table = new Table(programmers, waiters, dishes); + table.run(); + + for(var p : table.getInfo()) + System.out.println(p.getEaten()); + + System.out.println("total: " + table.getInfo().stream().mapToInt(i -> i.getEaten().get()).sum() + " dishes eaten"); } } \ No newline at end of file diff --git a/src/main/java/org/labs/Programmer.java b/src/main/java/org/labs/Programmer.java new file mode 100644 index 0000000..d155364 --- /dev/null +++ b/src/main/java/org/labs/Programmer.java @@ -0,0 +1,75 @@ +package org.labs; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class Programmer implements Runnable { + + private final Semaphore semaphore = new Semaphore(0); + private final Semaphore doneSemaphore = new Semaphore(0); + private volatile boolean runnable = true; + private final Runnable r; + private final AtomicInteger eaten; + private final AtomicBoolean hasWaiter; + + public Programmer(Runnable r) { + this(r, new AtomicInteger(0), new AtomicBoolean(false)); + } + + public Programmer(Runnable r, AtomicInteger eaten, AtomicBoolean hasWaiter) { + this.r = r; + this.eaten = eaten; + this.hasWaiter = hasWaiter; + } + + public AtomicInteger getEaten() { + return eaten; + } + + public void setEaten(int value) { + eaten.set(value); + } + + public AtomicBoolean getHasWaiter() { + return hasWaiter; + } + + public void setHasWaiter(boolean value) { + hasWaiter.set(value); + } + + @Override + public void run() { + while (runnable) { + try { + semaphore.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println(e.getMessage()); + } + if (!runnable) break; + + job(); + } + } + + public void eat() { + semaphore.release(); + } + + public void awaitDone() throws InterruptedException { + doneSemaphore.acquire(); + } + + public void stop() { + runnable = false; + semaphore.release(); + } + + private void job() { + r.run(); + eaten.getAndIncrement(); + doneSemaphore.release(); + } +} \ No newline at end of file diff --git a/src/main/java/org/labs/Spoon.java b/src/main/java/org/labs/Spoon.java new file mode 100644 index 0000000..6222a86 --- /dev/null +++ b/src/main/java/org/labs/Spoon.java @@ -0,0 +1,17 @@ +package org.labs; + +public class Spoon { + volatile private boolean onTable = true; + + public boolean isOnTable() { + return onTable; + } + + public void pickMe() { + onTable = false; + } + + public void putMeBack() { + onTable = true; + } +} diff --git a/src/main/java/org/labs/Table.java b/src/main/java/org/labs/Table.java new file mode 100644 index 0000000..48f6556 --- /dev/null +++ b/src/main/java/org/labs/Table.java @@ -0,0 +1,149 @@ +package org.labs; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +public class Table { + private final int dishes; + private final int programmers; + private final List spoons; + private final Lock lock = new ReentrantLock(); + private final Condition spoonsAvailable = lock.newCondition(); + private final List programmerList; + private final List programmerThreads; + private final ThreadPoolExecutor waiterPoolExecutor; + + public Table(int programmers, int waiters, int dishes, Runnable r) throws IllegalArgumentException { + this.dishes = dishes; + this.programmers = programmers; + + if (programmers < 2 || waiters < 1) + throw new IllegalArgumentException(); + + if (programmers < waiters) waiters = programmers; + + waiterPoolExecutor = new ThreadPoolExecutor( + waiters, waiters, + 100, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(dishes)); + + spoons = Stream + .generate(Spoon::new) + .limit(programmers) + .collect(Collectors.toCollection(ArrayList::new)); + + programmerList = IntStream + .range(0, programmers) + .mapToObj(i -> new Programmer(r)) + .limit(programmers) + .toList(); + + programmerThreads = IntStream + .range(0, programmers) + .mapToObj(i -> new Thread(programmerList.get(i), "" + i)) + .toList(); + } + + public Table(int programmers, int waiters, int dishes) { + this(programmers, waiters, dishes, () -> {}); + } + + public void run() { + programmerThreads.forEach(Thread::start); + + for (int i = 0; i < dishes; i++) { + waiterPoolExecutor.execute(() -> { + lock.lock(); + int n = getStarve(); + Programmer p = programmerList.get(n); + p.setHasWaiter(true); + lock.unlock(); + + Spoon leftSpoon = spoons.get(n); + Spoon rightSpoon = spoons.get((n + 1) % programmers); + + lock.lock(); + try { + while (!(leftSpoon.isOnTable() && rightSpoon.isOnTable())) { + try { + spoonsAvailable.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + p.setHasWaiter(false); + return; + } + } + leftSpoon.pickMe(); + rightSpoon.pickMe(); + } finally { + lock.unlock(); + } + + p.eat(); + try { + p.awaitDone(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + lock.lock(); + try { + leftSpoon.putMeBack(); + rightSpoon.putMeBack(); + spoonsAvailable.signalAll(); + } finally { + lock.unlock(); + } + + p.setHasWaiter(false); + }); + } + + waiterPoolExecutor.shutdown(); + try { + waiterPoolExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + for (var p : programmerList) p.stop(); + + for (var t : programmerThreads) { + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + public List getInfo() { + return programmerList; + } + + private int getStarve() { + int result = 0; + int min = Integer.MAX_VALUE; + + for (int i = 0; i < programmerList.size(); i++) { + if (programmerList.get(i).getHasWaiter().get()) continue; + + int temp = programmerList.get(i).getEaten().get(); + if (temp < min) { + min = temp; + result = i; + } + } + + return result; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..3b0c446 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +amount_around_table=7 +dishes=1000000 +waiters=2 From 8527d1248ad8b203fbb5606f11685e22750ff832 Mon Sep 17 00:00:00 2001 From: shulgan Date: Thu, 17 Sep 2026 15:54:16 +0300 Subject: [PATCH 2/2] Implement dining programmers simulation with fixes, tests and CI - Add Table/Programmer/Spoon: parameterized simulation of the dining programmers problem - Add JUnit 5 tests for Spoon, Programmer and Table, including a regression test asserting neighbours never eat at the same time - Add GitHub Actions CI workflow running the test suite on push/PR --- .github/workflows/ci.yml | 37 +++++++++ src/test/java/org/labs/ProgrammerTest.java | 68 +++++++++++++++++ src/test/java/org/labs/SpoonTest.java | 27 +++++++ src/test/java/org/labs/TableTest.java | 87 ++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/test/java/org/labs/ProgrammerTest.java create mode 100644 src/test/java/org/labs/SpoonTest.java create mode 100644 src/test/java/org/labs/TableTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ea322d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test --no-daemon + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: build/reports/tests/test diff --git a/src/test/java/org/labs/ProgrammerTest.java b/src/test/java/org/labs/ProgrammerTest.java new file mode 100644 index 0000000..a232f2a --- /dev/null +++ b/src/test/java/org/labs/ProgrammerTest.java @@ -0,0 +1,68 @@ +package org.labs; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ProgrammerTest { + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void eatRunsTheJobExactlyOnceAndIncrementsEaten() throws InterruptedException { + AtomicInteger jobRuns = new AtomicInteger(0); + Programmer programmer = new Programmer(jobRuns::incrementAndGet); + Thread thread = new Thread(programmer); + thread.start(); + + programmer.eat(); + programmer.awaitDone(); + + assertEquals(1, jobRuns.get()); + assertEquals(1, programmer.getEaten().get()); + + programmer.stop(); + thread.join(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void awaitDoneDoesNotReturnBeforeTheJobActuallyFinishes() throws InterruptedException { + AtomicInteger stage = new AtomicInteger(0); + Programmer programmer = new Programmer(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + stage.set(1); + }); + Thread thread = new Thread(programmer); + thread.start(); + + programmer.eat(); + programmer.awaitDone(); + + assertEquals(1, stage.get(), "awaitDone() вернулась раньше, чем job() реально завершилась"); + + programmer.stop(); + thread.join(); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void stopTerminatesTheRunLoop() throws InterruptedException { + Programmer programmer = new Programmer(() -> {}); + Thread thread = new Thread(programmer); + thread.start(); + + programmer.stop(); + thread.join(); + + assertFalse(thread.isAlive()); + } +} diff --git a/src/test/java/org/labs/SpoonTest.java b/src/test/java/org/labs/SpoonTest.java new file mode 100644 index 0000000..a6c2413 --- /dev/null +++ b/src/test/java/org/labs/SpoonTest.java @@ -0,0 +1,27 @@ +package org.labs; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SpoonTest { + + @Test + void newSpoonIsOnTable() { + Spoon spoon = new Spoon(); + + assertTrue(spoon.isOnTable()); + } + + @Test + void pickMeAndPutMeBackToggleState() { + Spoon spoon = new Spoon(); + + spoon.pickMe(); + assertFalse(spoon.isOnTable()); + + spoon.putMeBack(); + assertTrue(spoon.isOnTable()); + } +} diff --git a/src/test/java/org/labs/TableTest.java b/src/test/java/org/labs/TableTest.java new file mode 100644 index 0000000..a37423f --- /dev/null +++ b/src/test/java/org/labs/TableTest.java @@ -0,0 +1,87 @@ +package org.labs; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TableTest { + + @Test + void rejectsLessThanTwoProgrammers() { + assertThrows(IllegalArgumentException.class, () -> new Table(1, 1, 1)); + } + + @Test + void rejectsZeroWaiters() { + assertThrows(IllegalArgumentException.class, () -> new Table(4, 0, 1)); + } + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + void everyDishIsEatenExactlyOnceInTotal() { + int dishes = 500; + Table table = new Table(5, 2, dishes); + + table.run(); + + int total = table.getInfo().stream().mapToInt(p -> p.getEaten().get()).sum(); + assertEquals(dishes, total); + } + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + void dishesAreDistributedFairly() { + Table table = new Table(7, 3, 700); + + table.run(); + + List info = table.getInfo(); + int max = info.stream().mapToInt(p -> p.getEaten().get()).max().orElseThrow(); + int min = info.stream().mapToInt(p -> p.getEaten().get()).min().orElseThrow(); + + assertTrue(max - min <= 1, "разброс поеденных блюд между программистами: " + (max - min)); + } + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + void neighboursNeverEatAtTheSameTime() { + int programmers = 6; + + Map eating = new ConcurrentHashMap<>(); + AtomicBoolean violation = new AtomicBoolean(false); + + Runnable job = () -> { + int index = Integer.parseInt(Thread.currentThread().getName()); + int left = (index - 1 + programmers) % programmers; + int right = (index + 1) % programmers; + + if (Boolean.TRUE.equals(eating.get(left)) || Boolean.TRUE.equals(eating.get(right))) { + violation.set(true); + } + eating.put(index, true); + + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + eating.put(index, false); + }; + + Table table = new Table(programmers, 3, 150, job); + table.run(); + + assertFalse(violation.get(), "два соседних программиста одновременно держали общую ложку"); + } +}